├── .github └── workflows │ └── depctrl.yml ├── DependencyControl.json ├── LICENSE ├── README.md ├── doc ├── aegisubchain.md ├── misc_kara.md ├── perspective_math.md ├── perspective_motion.md ├── templaters.md └── templaters_idioms.md ├── macros ├── arch.AegisubChain.moon ├── arch.CenterTimes.lua ├── arch.ConvertFolds.moon ├── arch.DerivePerspTrack.moon ├── arch.FocusLines.moon ├── arch.GitSigns.moon ├── arch.Line2Fbf.moon ├── arch.NoteBrowser.moon ├── arch.PerspectiveMotion.moon ├── arch.RWTools.lua ├── arch.Resample.moon ├── arch.SplitSections.moon └── arch.TimingBinds.lua └── modules └── arch ├── Math.moon ├── Perspective.moon └── Util.moon /.github/workflows/depctrl.yml: -------------------------------------------------------------------------------- 1 | name: DepCtrl 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'macros/*.lua' 7 | - 'macros/*.moon' 8 | - 'modules/*/*.lua' 9 | - 'modules/*/*.moon' 10 | branches: 11 | - main 12 | 13 | jobs: 14 | # Modified version of PhosCity's workflow for depctrl which only runs when the script's version was changed 15 | depctrl: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Update record for bumped versions 23 | env: 24 | COMMIT_MSG: | 25 | Automatic update of hashes and script version 26 | run: | 27 | output_file=$(mktemp --suffix json) 28 | lastcommit="${{ github.event.before }}" 29 | 30 | tags=() 31 | 32 | echo "Previous commit was ${lastcommit}" 33 | 34 | while IFS= read -r file; do 35 | echo "Checking file ${file}..." 36 | 37 | version_pattern='(export\s)?script_version\s?=' 38 | ftype='macros' 39 | if [[ "${file}" == ./modules* ]]; then 40 | echo "File is a module." 41 | version_pattern='\s*version\s?[:=]' 42 | ftype='modules' 43 | fi 44 | 45 | if [[ "${lastcommit}" == "0000000000000000000000000000000000000000" ]]; then 46 | scriptver=$(grep -E "^${version_pattern}" "${file}") 47 | else 48 | # horrible hack but I couldn't get it working otherwise 49 | scriptver=$(git diff --raw -p "${lastcommit}" "${file}" | grep -E "^\+${version_pattern}" || echo "__continue") 50 | if [[ "${scriptver}" == "__continue" ]]; then 51 | continue 52 | fi 53 | fi 54 | 55 | echo "Version was changed with last push: ${scriptver}" 56 | 57 | VERSION=$(echo "${scriptver}" | cut -d '"' -f 2) 58 | 59 | # Uses sha1sum to find sha1. Outputs in `sha1 filename` format. 60 | CHKSUM=$(sha1sum "${file}") 61 | # Get current date in ISO 8601 format 62 | DATE=$(date -I) 63 | # Get actual sha1 of new file 64 | SHA=$(echo "${CHKSUM}" | awk '{print $1}') 65 | # Get the full filename of the Aegisub-scripts 66 | FULL_FILE=$(echo "${CHKSUM}" | awk -F'/' '{print $NF}') 67 | if [[ "${file}" == ./modules* ]]; then 68 | FULL_FILE=$(echo "${CHKSUM}" | awk -F'/' '{print $(NF-1)"."$NF}') 69 | fi 70 | # Namespace is the filename stripped of their extension. 71 | NAMESPACE=$(echo "${FULL_FILE}" | sed "s|.moon||g;s|.lua||g") 72 | 73 | echo "Version of ${NAMESPACE} was changed to ${VERSION}" 74 | echo "SHA: ${SHA}" 75 | echo "Date: ${DATE}" 76 | # Check if the file is added to DependencyControl or not and if version could be found or not 77 | if grep -q "${NAMESPACE}" DependencyControl.json && [[ -n "${VERSION}" ]]; then 78 | # Change sha1, date and version if the file was modified in last commit 79 | jq ".${ftype}.\"${NAMESPACE}\".channels.release.files[].sha1=\"${SHA}\" | .${ftype}.\"${NAMESPACE}\".channels.release.version=\"${VERSION}\" | .${ftype}.\"${NAMESPACE}\".channels.release.released=\"${DATE}\"" DependencyControl.json > "${output_file}" 80 | # Catch jq outputting an empty file on error 81 | if [[ -s "${output_file}" ]]; then 82 | mv "${output_file}" DependencyControl.json 83 | # Add new tag 84 | tags+=("${NAMESPACE}-v${VERSION}") 85 | echo "Successfully updated Dependency Control and tags for version ${VERSION} of ${NAMESPACE}." 86 | else 87 | echo "Something went wrong while processing ${FULL_FILE}. The file is empty." 88 | fi 89 | else 90 | echo "Either the file ${FULL_FILE} is not added to Dependency Control or version could not be found in the script. Skipping changing hashes." 91 | fi 92 | 93 | done < <(find ./macros ./modules/arch -name "*lua" -o -name "*.moon" -type f) 94 | 95 | # Commit changes 96 | git config user.name github-actions 97 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 98 | git add DependencyControl.json 99 | # Only commit and push if we have changes 100 | git diff --quiet && git diff --staged --quiet || git commit -m "${COMMIT_MSG}" 101 | 102 | for tag in "${tags[@]}"; do 103 | git tag "$tag" 104 | done 105 | - name: Push changes 106 | run: | 107 | git push --all 108 | git push --tags 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 arch1t3cht 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aegisub-Scripts 2 | My automation scripts for Aegisub. In my opinion, the coolest thing here is AegisubChain, but I also have some other useful scripts for editing and timing. 3 | 4 | - [Aegisub-Scripts](#aegisub-scripts) 5 | - [Installation](#installation) 6 | - [Guides](#guides) 7 | - [AegisubChain](#aegisubchain) 8 | - [Scripts for Typesetting](#scripts-for-typesetting) 9 | - [Focus Lines](#focus-lines) 10 | - [PerspectiveMotion](#perspectivemotion) 11 | - [Derive Perspective Track](#derive-perspective-track) 12 | - [Resample Perspective](#resample-perspective) 13 | - [Perspective](#perspective) 14 | - [Scripts for Editing and QC](#scripts-for-editing-and-qc) 15 | - [Rewriting Tools](#rewriting-tools) 16 | - [Note Browser](#note-browser) 17 | - [Git Signs](#git-signs) 18 | - [Scripts for Timing](#scripts-for-timing) 19 | - [Timing Binds](#timing-binds) 20 | - [Center Times](#center-times) 21 | - [Other Scripts](#other-scripts) 22 | - [Convert Folds](#convert-folds) 23 | - [Blender Export Scripts for After Effects Tracking Data](#blender-export-scripts-for-after-effects-tracking-data) 24 | - [See also](#see-also) 25 | 26 | ## Installation 27 | Most scripts I make use [DependencyControl](https://github.com/TypesettingTools/DependencyControl) for versioning and dependency management, and can be installed from within Aegisub using DependencyControl's Install Script function. 28 | Some of them strictly require it to be installed. 29 | 30 | ## Guides 31 | I wrote a [guide](doc/templaters.md) or primer on karaoke templates that aims to get people far enough to start reading documentation without too much pain. It also contains a few tables for converting templates between the three major templaters. 32 | 33 | I also wrote up the mathematics involved in the various perspective scripts [here](doc/perspective_math.md). 34 | 35 | ## AegisubChain 36 | My biggest project. From a technical standpoint, [AegisubChain](macros/arch.AegisubChain.moon) is comparable to a virtual machine that can run (multiple) other automation scripts, while hooking into their API calls. In particular, it can intercept dialogs and prefill them with certain values, or suppress them entirely by immediately returning whatever results it wants. 37 | 38 | From an end-user standpoint, AegisubChain allows you to record and play back "pipelines" of macros (called *chains*), and only showing one dialog collecting all required values on playback. It can also create wrappers around macros that skip some dialogs or prefill some values, or turn virtually any macro action into a non-GUI macro. 39 | 40 | Consider the following example, which records a 4-step process to make text incrementally fade from top to bottom, and later plays it back using just one script and one dialog: 41 | 42 | https://user-images.githubusercontent.com/99741385/202811305-41d3557c-952b-408e-9a5d-113b66efccc7.mp4 43 | 44 | Here's a second, simpler example which adds colors to text and runs "Blur & Glow". 45 | 46 | https://user-images.githubusercontent.com/99741385/168145566-4ef2dbbd-8afe-4e6c-8055-ae0beb0c69b4.mp4 47 | 48 | Other, simpler uses include turning simple actions like "Open a script; Click a button" into a non-GUI macro. For example, it could allow one to have (instant) key bindings for NecrosCopy's Copy Text or Copy Tags. 49 | 50 | Detailed documentation is [here](doc/aegisubchain.md). 51 | 52 | ## Scripts for Typesetting 53 | 54 | ### Focus Lines 55 | A script that generates moving focus lines, tweakable with a few parameters. 56 | 57 | **WARNING**: This script is dumb and horribly inefficient. I made it when I didn't know what I was doing. You can still use it, but make sure to clip the generated shapes to the frame area (plus some padding to account for the blur) and apply a Shape Clipper. Also be careful not to use too many layers. 58 | 59 | https://user-images.githubusercontent.com/99741385/180628464-2f970f02-b134-474b-b4b6-a998c22fcf75.mp4 60 | 61 | ### FBF-ifier 62 | Yet another line2fbf script. Meaning, this script turns lines into frame-by-frame chunks without changing the rendering output, replacing all transform (and fade and move) tags by constant tags in the process. This script should be more accurate than the other line2fbf scripts floating around. 63 | 64 | ### PerspectiveMotion 65 | An analogue to [Aegisub-Motion](https://github.com/TypesettingTools/Aegisub-Motion) that can handle perspective motion. Unlike the "After Effects Transform Data" that Aegisub-Motion needs, this tool requires an "After Effects Power Pin" track, which you can export directly from Mocha, or using [Akatsumekusa's plugin](https://github.com/Akatmks/Akatsumekusa-Aegisub-Scripts) for Blender. 66 | 67 | Detailed documentation [here](doc/perspective_motion.md) 68 | 69 | ### Derive Perspective Track 70 | More or less an analogue to [The0x539's DeriveTrack](https://github.com/The0x539/Aegisub-Scripts/blob/trunk/doc/0x.DeriveTrack.md) for perspective tracks. It turns the outer quads of a set of lines (as set using the perspective tool in [my Aegisub fork](https://github.com/arch1t3cht/Aegisub)) into a PowerPin track that can be used with [Aegisub Perspective-Motion](#perspective-motion). Alternatively, it can derive a track directly from the override tags. This way, manual perspective tracks can be made and applied to multiple different lines directly in Aegisub, without having to go through Mocha or Blender. 71 | 72 | ### Resample Perspective 73 | Run this [script](macros/arch.Resample.moon) after Aegisub's "Resample Resolution" to fix perspective rotations in the selected lines that were broken by resampling. If you're resampling to a different aspect ratio, select "Stretch" in Aegisub's resampler. 74 | 75 | There exist [multiple](https://github.com/TypesettingTools/CoffeeFlux-Aegisub-Scripts#scale-rotation-tags) [scripts](https://github.com/petzku/Aegisub-Scripts#resample) like this already, but this script uses a different approach to ensure exact accuracy. However, it still has a few limitations: 76 | - It still requires all individual events to have one consistent perspective and will not work if perspective tags change mid-line. In these cases you'll need to split the lines manually first. 77 | - It does not take position shifts due to large `\shad` values into account. If these become significant, you need to split the text from the shadow, adjust the positions, and resample them separately. 78 | - Shapes might need to be `\an7` to be positioned properly. 79 | 80 | ### Perspective 81 | I have a library [`arch.Perspective.moon`](modules/arch/Perspective.moon) that allows applying perspective transformations to subtitle lines. It abstracts away almost all of the tag wrangling and allows you to just work in terms of the quads you want to transform things from or to. All my perspective-related scripts use this library. 82 | 83 | Ordinary single-line perspective handling has been added directly to [my Aegisub fork](https://github.com/arch1t3cht/Aegisub). One day I might still write a script to cover some more advanced usage (e.g. a "perspective Recalculator"), but this is very low priority. 84 | 85 | For the math involved in these functions, see either the comments in the source code [this write-up](doc/perspective_math.md). 86 | 87 | ## Scripts for Editing and QC 88 | These scripts try to provide shortcuts for actions in editing or in applying QC notes. They're tailored to the processes and conventions in the group I'm working in, but maybe they'll also be useful for other people. 89 | 90 | ### Rewriting Tools 91 | This script is for whenever you're editing subtitles but want to also preserve the original line in the same subtitle line. It wraps the previous line in braces, but also escapes any styling tags it contains. Conversely, it can revert to any of the deactivated lines with one hotkey. 92 | 93 | https://user-images.githubusercontent.com/99741385/168145699-4076a81f-81f7-4ce7-baf5-6ac06f4a6cdb.mp4 94 | 95 | [The script](macros/arch.RWTools.lua) contains more detailed documentation. 96 | 97 | ### Note Browser 98 | Takes a list of subtitle QC notes (or really any collection of timestamped notes), each starting with a timestamp, and provides shortcuts for jumping to lines with notes, as well as a way to mark lines containing notes. If configured to, it will also show the notes themselves in Aegisub. 99 | 100 | https://user-images.githubusercontent.com/99741385/168145809-91e5f1ba-2a12-4003-8366-1bf8def09ab3.mp4 101 | 102 | Documentation is included in [the script](macros/arch.NoteBrowser.moon). Note, however, that this script is intended to be used more as a companion for working with the note file, instead of to replace it. Any text not matching the format of a timestamped note is skipped, so users should always double-check with the original note document. 103 | 104 | ### Git Signs 105 | If the subtitle file is part of a git repository, this script can parse the git diff relative to some other commit (or any ref, really) and highlight the lines which were marked as changed. This can be useful when reviewing edits made by another member, or when proofreading one's edits before pushing. 106 | 107 | ## Scripts for Timing 108 | These scripts aren't in DependencyControl, since I can imagine that they'll mostly be useful to me. 109 | 110 | ### Timing Binds 111 | A couple of shortcuts I use for more efficient timing, especially when timing to video. None of this is groundbreaking, but most of these were things I didn't find elsewhere, at least in this exact form. 112 | - Snapping start or end to video **while keeping lines joined**. 113 | - A version of [The0x539's JoinPrevious](https://github.com/The0x539/Aegisub-Scripts) that uses the current audio selection instead of the line's commited time. That is, it works without needing to commit the change if autocommit is disabled. 114 | - A version of Aegisub's "Shift selection so that the active line starts at the video frame" that shifts by frames instead of by milliseconds. 115 | - A version of the above macro that shifts all lines whose starting time is larger than or equal to the current line together with the selection. This is useful when retiming subtitles after scenes have been cut out of or added to the video. 116 | 117 | For reference, I usually time without TPP and without autocommit, but with "Go to next line on commit" on. I use this script together with [PhosCity's](https://github.com/PhosCity) Bidirectional Snapping 118 | 119 | ### Center Times 120 | Chooses the centisecond timings for subtitle lines in their frames in a way that prevents or minimizes frame timing errors (whenever possible) when shifting subtitles by time (e.g. when syncing files with [SubKt](https://github.com/Myaamori/SubKt)). The [script file](macros/arch.CenterTimes.lua) has a very detailed explanation. 121 | 122 | ## Other Scripts 123 | 124 | ### Convert Folds 125 | This script converts the line folds added in [my Aegisub fork](https://arch1t3cht/Aegisub) from the old storage format that used the Project Properties to the new extradata-based format. 126 | To use it, either 127 | - copy the "Line Folds:" line in your `.ass` file, open this file in Aegisub, and paste this into the dialog of the "Convert Folds" script, or 128 | - click the "From File" button to automatically read this line from the subtitle file. This only works if the file is saved on your disk. 129 | This will work on any version of Aegisub (i.e. an Aegisub version using extradata folds will be able to load folds from the resulting file), but in order for the folds to be displayed inside of Aegisub, you obviously need a build that supports extradata folds. 130 | 131 | ### Blender Export Scripts for After Effects Tracking Data 132 | You might be looking for my patched version of the After Effects Blender export script that adds the ability to export Power Pin data. This script has been superseded by Akatsumekusa's version, which is an almost complete rewrite with more features and a more user-friendly GUI. Go [here](https://github.com/Akatmks/Akatsumekusa-Aegisub-Scripts) to download this version. 133 | 134 | ## See also 135 | Or "Other Stuff I Worked on that Might be Interesting". 136 | - [ass.nvim](https://github.com/arch1t3cht/ass.nvim): A neovim 5.0 plugin for `.ass` subtitles. Its most important feature is a split window editing mode to efficiently copy new dialog (say, a translation) to a timed subtitle file. 137 | - My [Aegisub fork](https://github.com/arch1t3cht/Aegisub) with some new features like folding and other audio/video sources. 138 | 139 | --- 140 | Thanks to [PhosCity](https://github.com/PhosCity) for testing many of my early scripts. 141 | -------------------------------------------------------------------------------- /doc/aegisubchain.md: -------------------------------------------------------------------------------- 1 | # AegisubChain 2 | - [AegisubChain](#aegisubchain) 3 | - [Introduction](#introduction) 4 | - [Basic usage](#basic-usage) 5 | - [Detailed Documentation](#detailed-documentation) 6 | - [Script Registration and Execution](#script-registration-and-execution) 7 | - [Recording Process](#recording-process) 8 | - [Chain config format](#chain-config-format) 9 | - [Limitations and Workarounds](#limitations-and-workarounds) 10 | - [Compatibility](#compatibility) 11 | - [API](#api) 12 | - [Possible future features](#possible-future-features) 13 | 14 | ## Introduction 15 | From a technical standpoint, [AegisubChain](macros/arch.AegisubChain.moon) is comparable to a virtual machine that can run (multiple) other automation scripts, while hooking into their API calls. In particular, it can intercept dialogs and prefill them with certain values, or suppress them entirely by immediately returning whatever results it wants (which I'll call autofilling). 16 | 17 | From an end-user standpoint, AegisubChain allows you to record and play back "pipelines" of macros (called *chains*), and only showing one dialog collecting all required values on playback. It can also create wrappers around macros that skip some dialogs or prefill some values, or turn virtually any macro action into a non-GUI macro. 18 | 19 | Consider the following example, which records a 4-step process to make text incrementally fade from top to bottom, and later plays it back using just one script and one dialog: 20 | 21 | https://user-images.githubusercontent.com/99741385/202811305-41d3557c-952b-408e-9a5d-113b66efccc7.mp4 22 | 23 | Here's a second, simpler example which adds colors to text and runs "Blur & Glow". 24 | 25 | https://user-images.githubusercontent.com/99741385/168145566-4ef2dbbd-8afe-4e6c-8055-ae0beb0c69b4.mp4 26 | 27 | Other, simpler uses include turning simple actions like "Open a script; Click a button" into a non-GUI macro. For example, it could allow one to have (instant) key bindings for NecrosCopy's Copy Text or Copy Tags. 28 | 29 | ## Basic usage 30 | - Use "Record Next Macro in Chain" to begin recording a chain, and select what macro you'd like to run. 31 | - Use the macro's dialogs as you normally would, but pay attention to only change values in fields that are actually relevant 32 | - Continue by recording the next macro in your chain, but don't change the selection or any other settings like video position between finishing the last macro and recording the next. (More precisely, you can of course change them, but know that AegisubChain won't notice the changes. So changing the video position is fine if none of the scripts from that point on use the video position. Changing the selection is only a good idea if you know exactly what you're doing - see below for more details) 33 | - When you're done, run "Save Chain". In the dialog, you'll see a list of all dialogs that have been opened throughout, and, for each of them, a list of all fields that were changed in this dialog. For each field, you can choose if you want the field to always be filled with this value when running the chain (i.e. "Constant"), or if you want to type in its value when running the chain (i.e. "Set by User"). You can also set the default (or constant, if you chose "Constant") value for this field, as well as the field's label in the dialog shown when running the chain. 34 | - You can do the same for buttons: "Constant" will always use the chosen button to close the respective dialog. "Set by User" will offer a drop-down menu when running the chain. 35 | - Finally, set the name for your chain in the top left and save it. 36 | - Then, reload your automation scripts. Your chain will appear under "AegisubChain Chains" (unless configured otherwise). 37 | 38 | ## Detailed Documentation 39 | The above should work for most simple purposes. The following will be helpful to understand more exactly what AegisubChain can and can't do, and how to make it work in more tricky cases. 40 | 41 | ### Script Registration and Execution 42 | When it first needs them, AegisubChain will scan for lua or moonscript files in its path (which is configurable, but defaults to Aegisub's auto-load path), and run them while intercepting calls to `aegisub.register_macro`. Just as happens internally in Aegisub, each script will be loaded once, and its captured variables and global variables will persist throughout - until automation scripts are reloaded. Thus, each script will have a global state that transcends chains, but is entirely separate from the state of the script that was loaded directly by Aegisub. 43 | 44 | AegisubChain tries its best to separate the environments of the scripts it loads from each other. It does so by keeping track of which global variables were created by which scripts, and swapping them back and forth when switching between scripts. See the comments in [the script](macros/arch.AegisubChain.moon) for more detailed information. 45 | 46 | ### Recording Process 47 | When recording a macro, AegisubChain will intercept each call to `aegisub.dialog.display` and show the same dialog itself. It will then record all the fields (where "fields" means any editable part of a dialog, including dropdowns and checkboxes) whose values have been changed (i.e. whose values differ from whatever their values where when the dialog was first shown). It will also record which button was pressed. 48 | 49 | If the option "Show dummy dialogs first" is enabled when recording a macro, then each dialog that that macro shows will be shown twice. The first dialog is used only by AegisubChain, and it's where it will detect what fields have been changed. The second dialog's values are passed on to the macro without any sort of recording. This is useful when the user wants to mark certain values as "changed", but doesn't actually want to change them in this specific iteration of running the macro. 50 | 51 | Apart from the macro names themselves, this is also the only thing that AegisubChain will record. It will not record any changes in selection, video position, or any changes you make to the subtitles in between recording steps. 52 | 53 | A dialog field is described by its internal `name` field in the dialog definition table (and not by any other value like its position in the window). 54 | 55 | AegisubChain will record the dialogs that appear by their order of appearance (as opposed to any intrinsic feature of the dialog). Whatever dialog appeared first when running a macro will be assumed to always be the first dialog to be shown when running this macro. 56 | 57 | When saving a chain, it will allow you to, for each changed field and each button, configure how AegisubChain should handle it. The defining setting is the "Value Mode" dropdown to the very right: 58 | - If a field's mode is set to "Exclude", it will be ignored when saving the chain (i.e. it will be treated as if it had never been changed by the user). If a button's mode is set to "Exclude", its respective dialog will be skipped by the chain, *as if it had never appeared*. (Which for example means that, if this is applied to the first dialog in a macro, from now on the second dialog will be treated as if it was the first.) 59 | - If a field's mode is set to "Constant", it will be autofilled with whatever value is entered in its corresponding field in the save dialog (which in turn defaults to what the user entered when recording). If a button's mode is set to "Constant", the dialog will always be closed by pressing the button selected in the respective dropdown. 60 | - If a field's mode is set to "Set by User", the user will be prompted to enter that field's value at the beginning of the chain's playback, with the default value of that field being whatever is entered in the corresponding field in the save dialog. The user can also set a label for this field, which will appear in front of said field in the playback dialog. If a button's mode is set to "Set by User", the user will be offered a drop-down menu in a similar manner to select what button to close the dialog with. 61 | 62 | Furthermore, there are two additional modes for buttons (which actually affect their entire respective dialogs). 63 | - The option "Raw Passthrough" will *not* autofill any values in said dialog, and instead show this dialog to the user without any modifications when running the chain. For example, this can be useful for info dialogs like "XYZ lines changed". 64 | - The option "Passthrough with defaults" will also *not* autofill any values in said dialog, and show the dialog to the user, however it will *prefill* some fields with whatever values have been chosen for them - either in "Constant" mode or in "Set by User" mode. (The usefulness of the latter mode is debatable, as the same field would be shown at two occasions now, but it is allowed.) 65 | 66 | These two options both translate to the same underlying mode `passthrough`. "Raw Passthrough" simply applies "Passthrough with defaults", but sets all fields to "Exclude" first. 67 | 68 | When saving a chain, AegisubChain will automatically compute the layout for the dialog that will be shown on chain playback: There's one row for each dialog (which has the mode "Set by User" set somewhere), and the fields are sorted in their respective rows by where they appeared in the dialog they correspond to. This dialog layout can be changed by editing the chain (either in the config file or by exporting and importing): It essentially follows the format of a dialog control table, although it will be stretched horizontally by AegisubChain to also add in the labels. 69 | 70 | Finally, the save dialog also offers to choose a "Selection mode" for each script. This setting controls what lines will be selected (and what will be the active line) after running a step in the chain (and thus which selection and active line will be fed to the next chain): 71 | - The default option "Macro's Selection" will select whatever selection the macro returned. If none is returned, it will apply "Previous Selection" as a fallback. 72 | - "Previous Selection" will select whatever lines were selected before running the step. (More precisely, it will take the previous selection and shift indices to account for lines being inserted or deleted). 73 | - "Changed lines" will select all lines which the script changed or inserted. 74 | 75 | When playing back a chain, AegisubChain will first show a dialog prompting for the fields it has been configured to ask the user for. The dialog control tables are read from the config. If no fields or buttons have been set to "Set by User", no dialog will be shown. 76 | 77 | AegisubChain will proceed to run each of the macros in the chain. In each step, for the i-th dialog that opens, it will fill its fields (again going by the `name` field) with the values obtained for the i-th dialog (including constant values) in the chain definition. If the button's mode is `passthrough`, it will proceed to show it to the user - otherwise it will automatically "close" the dialog using the specified button. Once the script finishes, it will track how the selection changed (this is done by passing a dummy `subtitles` object to the script, which has a metatable set that logs all relevant actions and forwards them to the actual `subtitles` object), and compute the selection that should be returned or passed on to the next step's macro. 78 | 79 | ### Chain config format 80 | AegisubChain used DependencyControl's ConfigHandler to store its configuration and its chains in json format. Refer to the script and its configuration dialog for documentation of the global configuration options. The following explains the chain format: 81 | - A chain is an array of steps 82 | 83 | A step is a table containing one of the following fields: 84 | - It **must** contain a field `script`, which is the name of the script as registered in Aegisub. 85 | - It **should** contain a field `select`, whose value is one of `macro`, `changed`, or `keep`, and which controls what lines will be selected after running this step. The default value is `macro`. 86 | - It **must** contain a field `dialogs`, which is an array of dialog info tables. 87 | 88 | A dialog info table is a table that can contain the following fields: 89 | - It **can** contain a field `fields`, which is a table containing field info tables for various field names. A field info table is a table that can contain the following fields: 90 | - It **should** contain a field `mode`, which controls how this field is autofilled or prefilled in dialogs. Possible values are `const` and `user` (the default). 91 | - If the mode is `const` **should** contain a field `value`, which sets the value the field should be autofilled with. 92 | - If the mode is `user`, it **must** contain fields `class`, `x`, `y`, `width`, `height`, `items` and **should** contain fields `label`, `hint`, `text`, `value`, `min`, `max`, `step` whenever applicable (refer to the documentation of Aegisub's dialog control tables). These values control how the respective field is shown in the chain playback dialog. 93 | - If the mode is `user`, it **must** contain a field `flabel` determining the label for that field in the chain playback dialog. 94 | - It **must** contain a field `button`, which contains a button info table which specifies how this dialog should be handled. This table has the same format as a field info table, but without a `class` field, and with the additional allowed value `passthrough` for the mode. 95 | 96 | ## Limitations and Workarounds 97 | This section lists some consequences of what has been described above, and how to possibly work around them. Some of these are obvious, but could still be interesting. 98 | - It bears repeating that **AegisubChain will not pick up on any changes made to subtitles or the selection between recording steps**. Every single step of the chain needs to be run as a macro, with the exception of the user being able to choose between selection modes. Macros like Selectricks or Selegator can be used for more control over selection. 99 | - AegisubChain recognizes dialog fields by their internal `name` fields, and recognized dialogs by the number of dialogs that appeared before them. Thus, it will not work well with scripts which only sometimes show dialogs, or with scripts which generate them dynamically. 100 | - AegisubChain does not attempt to find out *how* a dialog field was changed. For example, when running HYDRA in full mode on the text `Test`, and entering `Tes*t` in "Tag position" to apply tags to the last character, AegisubChain will just interpret this as setting this field to `Tes*t`, and will thus always prefill or autofill that exact value, no matter what the line's text is. Settings like the "Presets" next to "Text position" should be used instead. 101 | - AegisubChain will fill a field that is not recorded in the chain's config with its default value. This can become problematic when default values are not constant, such as when they're set to the value entered during the last run. For example, Hyperdimensional Relocator's default repositioning mode on its first run is `clip2frz`. On subsequent runs, its default mode is whatever mode was last used. Thus, recording a chain with `clip2frz` would by default always use the mode that was last selected, instead of using `clip2frz`. 102 | 103 | To avoid this issue, the option "Show dummy dialogs first" can be used when recording the chain: By changing the mode to *anything* for the first dialog, and then running Relocator with `clip2frz` for the second dialog, the intended effect is achieved and AegisubChain will pick up the field as changed. Then, in the save dialog, the constant value for the mode can be set back to `clip2frz`. 104 | - As of now, AegisubChain does not treat DependencyControl any different than any other module. Thus, instead of intercepting the macro registrations with DependencyControl, it intercepts the actual call to `aegisub.register_macro` which DependencyControl performs after that. This means that macros will be listed in AegisubChain with the same name they're registered with in Aegisub, which will include any custom folders made by DependencyControl's Macro Configuration. 105 | 106 | This makes chains less portable, as they'll depend on the macro configuration of the user playing them. However, preventing this would mean intercepting calls to DependencyControl, which loses some of the script's universality. So, for now, editing the macro names in the chain's definition is the only way to fix this. 107 | 108 | A possible alternative solution would be making a third-party tool that can adjust a chain to any user's Macro Configuration (or include an option for such a filter when importing and exporting macros). 109 | 110 | - As of now, chains cannot be edited after saving them, and chain playback dialogs can't be customized when saving chains. For now, editing the chain's config (either by editing the config file or by exporting and importing) is the only way to customize them. 111 | 112 | ## Compatibility 113 | This could be interesting for people trying to write Aegisub scripts that work well with AegisubChain. In order of importance: 114 | - Modules need to use the modern lua convention for modules (i.e. returning a table containing all its function, instead of just exporting them) to work well. Any modules that don't follow this need to be treated individually by AegisubChain. 115 | - Don't change global variables that are present by default, and don't change the entries of any imported modules 116 | - Make your dialogs and their field names as predictable as possible 117 | - Don't do any other forbidden things like 118 | - Changing the `aegisub` variable 119 | - Doing type checks on the subtitles object obtained from Aegisub, or touching its metatables. 120 | 121 | I'm the only one allowed to do those. 122 | 123 | ### API 124 | In case you need to know if your script is being run by AegisubChain, here's a list of global variables which exist in this case: 125 | - `_ac_present`: True if your script was loaded by AegisubChain. 126 | - `_ac_version`: AegisubChain's version 127 | - `_ac_config`: AegisubChain's config handler. `_ac_config.c` is its configuration table. Please be nice and don't break it. 128 | - `_ac_aegisub`: The real aegisub object. 129 | 130 | Any other global variables are unstable and could change at any point in time. 131 | 132 | ## Possible future features 133 | Some things I might implement in the future, if I have the time and am sufficiently satisfied with the design: 134 | - Paging for the "Save Chain" and "Manage Chains" dialogs. This will definitely be necessary for more complex chains, but it's a pain to build, which is why I haven't done it yet. 135 | - Some form of Lua eval in fields. This would remove most of the inflexibility the script has right now. Simple applications would be converting values before entering them in dialogs, or entering the same value in two different fields. More complex applications could be something like making dialog frontends for LuaInterpret scripts by filling some of the constant definitions with values that were picked in a dialog (e.g. by evaluation expressions surrounded by `!` as in karaoke templates.). 136 | 137 | The most difficult part here is finding an API which is both flexible and does not involve too much boilerplate code in these expressions. (Ideally they could not only access the results of the playback dialog, but also the current dialog's info, as well as subtitles both before running the chain and at the current point in time.) This is why I've been holding off on this feature for now. 138 | - Adding more options to edit chains via dialogs. I'm also a bit hesitant here, since making every single aspect editable will make editing them very tedious. Drawing the line at what should be changeable in dialogs and what shouldn't has been hard. A standalone application to edit chains could be a good idea, but it would involve a lot more work. 139 | - To help with the fact that every step of the chain has to happen in a macro, it could be helpful to make a script containing a collection of "companion" macros for AegisubChain that allow things like managing the selection, video and audio position (this would be simulated instead) or the current subtitle line. All of these are actions which would often just be done manually, so there aren't too many scripts for them. 140 | - Some online place to find or share recorded chains. 141 | -------------------------------------------------------------------------------- /doc/perspective_math.md: -------------------------------------------------------------------------------- 1 | # The math behind perspective in .ass subtitles 2 | This page writes up most of the math involved in the various perspective scripts floating around. 3 | It starts with explaining how [perspective.py](https://github.com/TypesettingTools/Perspective) and its Moonscript version [perspective.moon](https://github.com/Alendt/Aegisub-Scripts/blob/master/perspective.moon) work, and goes on to explain what I found out on top of this and how I used it in [Perspective-motion](https://github.com/Zahuczky/Zahuczkys-Aegisub-Scripts/tree/daily_stream) and my hopefully eventually upcoming improved perspective script. 4 | 5 | This is mostly a compiled version of the long rundown I gave on Discord, so it's not too formal or polished yet. Still, it's better than no documentation at all. A more formal write-up might follow eventually. 6 | 7 | ## How does perspective.py or (Alendt's) Perspective.moon work? 8 | Perspective.moon takes the four corner points of a quadrilateral, and outputs transform tags that would transform text to have perspective matching that quadrilateral. That is, assuming that the given 2D quad is the 2D projection of a three-dimensional rectangle, the tags will transform the text into the same 3D plane as that rectangle. 9 | 10 | To see how this works, let's remember what transform tags are applied in what order: 11 | 1. `\fax` and `\fay` (`\fay` is useless so let's just ignore it) 12 | 2. `\fscx` and `\fscy` 13 | 3. `\frz` 14 | 4. `\frx`, and then `\fry` 15 | 5. The previous step turned this into a 3D object, so we project back into 2D. This is done by adding $312.5$ to all $z$ coordinates and then projecting all points to the $z=312.5$ plane by multiplying each point by $312.5$ divided by their $z$ coordinate. 16 | (Geometrically, after we translated, we draw a line from each point to the origin $(0, 0, 0)$ and intersect that line with the $z=312.5$ plane) 17 | 18 | Note that if `\fax` and `\fay` are $0$, then after step 4 the text is a (3D) rectangle. 19 | If `\fax` is not $0$, then it's only a parallelogram. 20 | 21 | Suppose we now want to find tags that transform a rectangle such that the quad resulting after steps 1-5 is the quad we were given. We start with the quad and undo the steps 5-1 one by one. This is done by the [`unrot`](https://github.com/Alendt/Aegisub-Scripts/blob/476e6309c0bc4baac736ba17b7dcc3969266a54e/perspective.moon#L70) function in perspective.moon, which is also the only really relevant function in the script. The others are ugly exhaustive searches, more on that later. 22 | 23 | Now, the first step we need to undo is step 5, the projection. Given a 2D quad in the $z=312.5$ plane, we want to find a 3D parallelogram that projects onto that quad. 24 | Equivalently, given such a 2D quad, we can draw the four lines from the origin $(0, 0, 0)$ through its four corners, and find a point on each of these lines, such that the quad that they build is a parallelogram. 25 | It turns out that for each quad there is exactly one such parallelogram, up to scaling. 26 | You can also easily compute it, just by solving the linear system of equations that comes out of this. 27 | 28 | Alternatively, perspective.moon uses a nice geometric trick, exploiting that parallelograms are characterized by their diagonals bisecting each other exactly: It starts by computing the "center" of the quad by intersecting the diagonals. 29 | Then, for each of the two diagonals, it multiplies the two corresponding points by a factor such that the areas of the two triangles point-center-origin are equal (by just multiplying on e point by the ratio of these areas, which are computed using the cross product). 30 | The areas being equal is actually equivalent to the lengths of the point-center segments being equal, so this gives us the ratios we want. 31 | Then, the three points are scaled again so that the center ends up where it was originally. 32 | This is done for both diagonals, and the resulting four points make up the parallelogram we wanted. 33 | 34 | Now, we've found our parallelogram. 35 | Let's assume for a second that we lucked out and it's a rectangle. 36 | Then, we have a rectangle in 3D space and need to find rotation tags that rotate a 2D rect into the same plane as this 3D rect. 37 | Equivalently, we want to apply y,x,z rotations (in that order; the opposite order to the original transformation) that rotate our 3D rectangle back into a horizontal planar 2D rect. 38 | This is comparatively simple - you just compute the normal vector and do some trigonometry to find the `\fry` and `\frx`. Then you apply that rotation to actually get a 2D rect in the plane, and finally do more trigonometry to find the `\frz` that makes this back into a horizontal rectangle. 39 | And that's it! 40 | We've transformed our quad back into a horizontal 2D rect. 41 | Conversely, if we start out with such a rect (like any text) and apply the corresponding tags, we'll get a quad that matches the perspective of the quad we put in. 42 | 43 | This is exactly what perspective.moon does, in its simplest case. But things can get a little more complicated: 44 | First of all, we assumed that we got a rectangle out of our inverse projection computation. 45 | If we don't get a rectangle, but only a parallelogram, then we can in principle repeat all of the same computations to invert `\fry`, `\frx`, `\frz`. 46 | In the end, we'll get a horizontal planar parallelogram. 47 | Then we can just find the `\fax` that turns a rectangle into that parallelogram. 48 | If you run perspective.moon with "Transform for target org", that's exactly what happens. 49 | 50 | But `\fax` is ugly and messes up positioning (this is not the only reason - not needing `\fax` is a sign that you've found the right origin, and if you're lucky you can typeset multiple pieces of text with the same tags and origin), so perspective.moon includes code to work around that: 51 | One thing I have glossed over so far is that it's not clear where the (2D) origin point of our quad is. 52 | That is, depending how you translate your input quad in 2D space, you can get different results (well, the unproject-coefficients actually stay the same, but everything else changes). 53 | In particular, if you translate the input quad before doing the unprojection steps, you can get closer to or further away from your unprojected parallelogram becoming a rectangle. 54 | In .ass terms, "Translating the input quad" means changing `\org`, since that's the origin relative to which all projections happen. 55 | So, we can try to search for a value of `\org` that hopefully gives us a rectangle after unprojecting. 56 | 57 | Perspective.moon just does that with a lot of brute force searching. 58 | The full form of the `unrot` function takes a quad *and* an `\org` and does all of the computations above. 59 | But after unprojecting, it also computes the `diag_diff` number as $\frac{d_1-d_2}{d_1+d_2}$, where $d_1$ and $d_2$ are the lengths of the diagonals. 60 | The point being that a parallelogram is a rectangle if and only if its diagonals have the same lengths. 61 | So this number ranges from $-1$ to $1$, and it's equal to $0$ if and only if the parallelogram is a rect, so if and only no `\fax` is needed. 62 | 63 | So perspective.moon just searches for `\org` points where this `diag_diff` value comes out close to $0$. 64 | It starts by searching in a huge but not very fine range for maxima and minima of `diag_diff`. 65 | Then it searches for a zero of the segment connecting the maximum and minimum. 66 | Then it keeps rotating the segment around one of those points and searches for further zeros. 67 | Finally, out of all candidate points it picks the one which is closest to the actual center of the quad, or the one where the ratio of the rectangle's sides is the closest to the target ratio. 68 | 69 | ## Improvements upon perspective.moon 70 | First of all, it's possible to turn the `\org` search into more explicit calculations if you do a bit more math. 71 | The `diag_diff` value is harder to handle, but it gets a lot easier if you just take the scalar product of two sides of the parallelogram instead. 72 | This is also $0$ if and only if the parallelogram is a rectangle, but most importantly it's nice and linear. 73 | If you write down all the equations for the unprojection and in the end solve for the scalar product being $0$, it turns out that you always get polynomial equation in two variables of degree at most $2$. 74 | And, in fact, it always cuts out a circle, or in rare cases degenerates to a linear equation. 75 | So you can find actually find optimal `\org` points more precisely. 76 | 77 | Moreover, while perspective.moon does the unrot computations I described above and outputs `\org`, `\fax`, and rotation tags, these are also the only tags it outputs. 78 | It doesn't give any `\fscx` or `\fscy` values. 79 | So its output tags don't always transform the text onto the quad exactly - they only transform it into the same plane as the quad. 80 | Since the original scene might have been drawn or rendered with a different focal length than the $312.5$ that .ass uses, the `\fscx` or `\fscy` often need to be adjusted. 81 | For typesetting static scenes this isn't a problem, since you can adjust them manually. 82 | But it becomes crucial when you want to do perspective tracking, since the scaling needs to stay consistent when the perspective changes. 83 | 84 | So how do you fix this? 85 | If you just want to transform your text exactly onto some quad, it's simple: You just start by running perspective.moon's calculations. 86 | Once that's done, you run the 3D quad through all the inverse transformations and get a horizontal 2D rect. 87 | Then you can just compare the width and height of that rect with your text, and adjust accordingly. 88 | 89 | ... actually, this is not what I did in persp-mo. 90 | Mostly because I didn't actually think of that solution back then (tunnel vision after working on other components where this didn't work well), but also because this stops working well as soon as you don't want to transform to the quad, but only to some part of it (see below). 91 | Instead, I did the same computation, just on an infinitesimally small rectangle. 92 | That is, after finding the transform tags, I took the resulting transformation (including projecting) as a mathematical function from the plane to itself and let Mathematica compute the Jacobian (all partial derivatives) to find out how much this scales a rectangle in the $x$ or $y$ direction. 93 | Then, I did the same for the mathematical perspective transformation transforming a 1x1 square to the quad (see below for how to find that), and compared the two scaling factors. 94 | In the end, these approaches are more or less equivalent, but this one is quicker, and a lot fancier. 95 | 96 | ## Perspective tracking and all the rest 97 | Now, you might think that we figured out scaling now, but that's not yet the full story. 98 | Usually, you don't want your text to fill the whole quad you're tracking from edge to edge (both horizontally and vertically). 99 | You also don't want your text to be positioned precisely at the center of that quad. 100 | You can fix the first point by manually scaling afterward, but the second point needs you to a) track the position of the text properly, so it stays at the same position (in terms of perspective) relative to the moving quad, and b) make the scaling match - if you have a 3D quad, then sections closer to the screen will be bigger than sections further away, and this ratio changes when the quad moves or rotates. 101 | 102 | Pragmatically, you can do part a) by just tracking the point with Mocha and Aegisub-Motion, if that's feasible. 103 | But part b) needs more work. 104 | 105 | So, the remaining question is more or less this: Given two quads, and a point on the first quad, what's the "corresponding" point on the second quad? 106 | Or, how do I compute the "perspective transformation function" (if you care, this means a function of the form $(x, y) \mapsto \left( \frac{a_1+a_2x+a_3y}{c_1+c_2x+c_3y}, \frac{b_1+b_2x+b_3y}{c_1+c_2x+c_3y} \right)$, or if you're a math guy, an element of the projective linear group $\mathbb P\mathrm{GL}_3$) from the 2D plane to itself that maps one quad onto the other? 107 | In fact, does there even exist such a function?! 108 | 109 | So, yes, projective geometry tells us that for any two (non-degenerate) quads (with numbered corners), there is exactly one projective transformation mapping the corners of one quad to the other's. 110 | As for how to compute them, let's just reduce this to a simpler case: Start by for any quad finding a projective transformation mapping that quad to the 1x1 square, as well as its inverse. 111 | Then you can go from any quad to any other quad by going from the first quad to the 1x1 square to the second quad. 112 | 113 | And if you think about it a bit, we've already found such a transformation! 114 | After all, we've found transform tags transforming such a 1x1 square to any quad, and we can also easily invert each of the transformations involved (projecting is the only hard part there you just draw the line through the point and the origin, and intersect it with the plane through the 3D rect). 115 | That comes out to be a perfect projective transformation. 116 | 117 | However, a) I didn't realize that at first and, b) computing the transformation involved taking trigonometric functions of the quad's coordinates, which isn't nice. 118 | Actually, the main theorem of projective geometry works over any field, so the transformation should just be expressible as a rational function. 119 | You could write down the transformation symbolically and simplify everything, or you can just take an approach from scratch: 120 | 121 | Another fun part of projective geometry is the cross-ratio: If you have four points $A, B, C, D$ on a line in that order, their cross-ratio is defined as $\frac{\lvert AC\rvert \cdot \lvert BD\rvert}{\lvert AD\rvert\cdot\lvert BC\rvert}$. 122 | The cool part is that one of these points is allowed to lie "at infinity" (this makes sense in projective geometry): If $D$ is the point at infinity, then $\lvert BD\rvert$ "cancels" with $\lvert AD\rvert$ and the cross-ratio is just $\frac{\lvert AC\rvert}{\lvert BC\rvert}$. 123 | 124 | So, cross-ratios are fun and all, but the whole reason why they're so important is that they're preserved under projective transformations (just like a rotation preserves lengths, for example). 125 | So if you have four such points $A, B, C, D$, and a projective transformation $\varphi$, then the cross-ratio of $\varphi(A), \varphi(B), \varphi(C), \varphi(D)$ will be the same as the cross-ratio of $A, B, C, D$. 126 | 127 | And you can use these to figure out the perspective transformation: If you want to transform a quad $ABCD$ to the 1x1 square (call the transformation $\varphi$), you can intersect the lines through two opposite sides $AB$ and $CD$ and call the intersection $F$ (suppose $B$ lies between $A$ and $F$ - all these assumptions cancel out later). 128 | Given a point $P$ on the quad, you can intersect $AD$ and $BC$ with the line through $PF$. 129 | If the intersection points are $S$ and $T$, you can find the $x$ coordinate of $\varphi(P)$ using the cross-ratio of $S,P,T,F$. 130 | The other computations and the inverse work similarly. 131 | 132 | So I typed all of that into Mathematica and let it compute and simplify all of that into rational functions involving the coordinates of the quad and the point. 133 | Then I copy-pasted the output into the code. 134 | This is found in both Perspective-motion and my [WIP perspective libary](../modules/arch/Perspective.moon). 135 | 136 | So with this we can find out how a point on the quad moves if the quad moves, so we can perspective track a point. 137 | And with this, we can also compute how scaling works away from the center of the quad: Like before, you can take the transformation mapping the quad to the unit square, and take the Jacobian *at* the point you're tracking. 138 | Then compare that to the Jacobian of the .ass transform tags (that one still has to be taken at the (relative) origin) transforming text around that point to the proper perspective. 139 | So, I let Mathematica also compute the derivatives of these rational functions, and also paste those into the code - and then that's finally it. 140 | 141 | That's more or less where we're at with persp-mo right now. 142 | Some other bits: 143 | - The `\org` search (neither perspective.moon's version nor mine) isn't involved - we just do everything with `\fax` right now 144 | - `\fax` messes up positioning, but at least in a predictable manner. So we just compute how much the position is offset and correct for it with `\pos`, while keeping `\org` where the position *should* be 145 | - Some fun facts: I went through a bunch of iterations with this. 146 | Before I bit the bullet and computed the transformations manually, I tried to instead find a focal length such that unprojecting would immediately output a rectangle when the origin was at the center of the screen (since `\org` searching on top of that would be really hard). 147 | I did this by assuming that the original 3D rectangle's aspect ratio would stay constant throughout and doing least-squares optimization. 148 | I'm glad I scrapped that, since the current solution is much cleaner and works in full generality. 149 | But I know that this code was used for at least one actual sign, so it wasn't completely useless. 150 | 151 | Finally, with this it's possible to transform arbitrary quads to arbitrary quads. So now it's also possible to do things like 152 | - Rotate an already perspecified line relative to the plane it's supposed to be in, so you can give something `\frz` (or any other tranformation) after adding perspective 153 | - Shift and already perspectified line in its plane 154 | - Take a 3D line and make it transform to the same quad, scaled by a factor. This would correctly resample rotations to higher resolutions. 155 | 156 | Hopefully, my better perspective will eventually be able to do all of these, once it's completed. -------------------------------------------------------------------------------- /doc/perspective_motion.md: -------------------------------------------------------------------------------- 1 | # PerspectiveMotion 2 | - [PerspectiveMotion](#perspectivemotion) 3 | - [Introduction](#introduction) 4 | - [Basic usage](#basic-usage) 5 | - [Options](#options) 6 | 7 | ## Introduction 8 | 9 | An analogue to [Aegisub-Motion](https://github.com/TypesettingTools/Aegisub-Motion) 10 | able to handle perspective motion. Unlike the "After Effects Transform Data" needed by Aegisub-Motion, 11 | this tool requires an "After Effects Power Pin" track, which you can export directly from Mocha, 12 | or with the help of [Akatsumekusa's plugin](https://github.com/Akatmks/Akatsumekusa-Aegisub-Scripts) from Blender. 13 | 14 | ## Basic usage 15 | 16 | First you’ll need to motion track your object to obtain the Power Pin data. 17 | The details of how to perform motion tracking are out of scope here, 18 | but when using Blender you might want to take a look at the examples from 19 | [Akatsumekusa’s plugin docs](https://github.com/Akatmks/Akatsumekusa-Aegisub-Scripts/blob/master/docs/aae-export-tutorial.md#tutorial-4-tracking-perspective). 20 | While currently not part of those examples, you might also find plane tracks 21 | useful for more complex scenarios; check Blender documentation for details. 22 | 23 | The most important part of the exported data are the positions of each of the four quad corners per frame. 24 | Per corner there’s one section in the exported data, each section starting with a line like: 25 | ``` 26 | Effects CC Power Pin #1 CC Power Pin-0002 27 | ``` 28 | 29 | With `0002` denoting the upper left, `0003` the upper right, `0004` the lower left and `0005` the lower right corner. 30 | You might need to fix up the orientation after exporting to ensure the above mapping holds. 31 | Crucially up/down, left/right here is considered from the point of view 32 | of the tracked object itself **not** in regular screen space! 33 | Put another way, while mapping corner ids, assume the scene is rotated such that the tracked object face 34 | directly points at the camera, its text baseline is horizontal and glyphs upright. 35 | 36 | Here are some examples 37 | 38 | ![perspectivemotion_pinorder_example01.png](https://github.com/user-attachments/assets/5c341658-7709-45fc-ad84-f02cbee382c1) 39 | ![perspectivemotion_pinorder_example02.png](https://github.com/user-attachments/assets/fa741f35-0dd7-4d57-8347-f6ac97363a27) 40 | ![perspectivemotion_pinorder_example03.png](https://github.com/user-attachments/assets/895fbedd-0961-450e-9aca-a601390eddda) 41 | 42 | If reordering is needed it suffices to just change the number in the opening line of each segment, the order of sections inside the exported data stream does not matter. 43 | 44 | Now just select the template line which you want to transform, make sure its start and end time match the motion-tracked frames, copy Power Pin data into the clipboard and run the script. 45 | 46 | ## Options 47 | 48 | - `Apply perspective`: if checked it’s assumed the line this is being applied to does not yet have any 49 | perspective transformations and an appropriate transformation is automatically derived from the ingested 50 | Power Pin data at the given frame. Note this automatically generated transform may not be scaled 51 | exactly as a tight fit of the Power Pin quad. 52 | If unchecked it’s assumed the line already is transformed and scaled appropriately for its frame 53 | and all transformations are applied relative to the existing line. 54 | Usually this will be (un)checked automatically when opening the dialogue depending on whether 55 | there already are any perspective transformation tags. 56 | 57 | - `Relative to frame`: specifies which motion tracking frame the reference line corresponds to. 58 | Always numbered starting from one and counting each successive motion tracking frame regardless 59 | of which frame number are listed in motion tracking data. 60 | 61 | - `\org mode`: the same as for the built-in perspective tool 62 | -------------------------------------------------------------------------------- /doc/templaters_idioms.md: -------------------------------------------------------------------------------- 1 | # Karaoke Templater Idioms, Snippets, and Tricks 2 | This page collects some common templating tricks and idioms. 3 | You can use it to learn new tricks, or as a kind of "cheat sheet". 4 | Of course, you should also refer to the respective templater's documentation (see [my general templater guide](templaters.md) for links). 5 | 6 | This list is written for The0x539's templater, but many of the techniques also transfer to other templaters. 7 | 8 | ### Setting a Random Seed 9 | If your template uses randomness, it may be helpful to set a constant seed at the top of your template by adding a `code once` with `math.randomseed(1337)` (or some other seed). 10 | That way, rerunning your template won't regenerate all random effects, which makes it easier to compare outputs. 11 | Once your template and lyrics are final, you can try different seeds if some randomly generated values look weird, but note that any change to your template or lyrics will probably shuffle all values around again. 12 | If you find yourself repeatedly needing to shuffle around the seed to get random values to look good, you may want to tweak your random distributions. 13 | For example, picking out five random angles on a circle rarely looks good and you usually want to start out with splitting a circle into five uniform pieces and then shuffling things around slightly. 14 | 15 | ### A Simple Random Range Function 16 | Put `function random(a, b) return a + (b - a) * math.random()` in a `code once` to get a function that gives you a random float in the given range (as opposed to Lua's built-in `math.random(a, b)` which only returns integers). 17 | 18 | ### Consistent Random Values 19 | Say you want to randomly shake a line that's split into multiple layers. 20 | Naively, you might try to write something like the following: 21 | ```lua 22 | Comment: 0,[...],template line,{\bord4\shad0\1a&HFF&} 23 | Comment: 1,[...],template line,{\bord0\shad0} 24 | Comment: 0,[...],mixin line ,!util.fbf()!{\blur2\an5\pos(!orgline.center + math.random(-10,10)!,!orgline.middle + math.random(-10,10)!)} 25 | ``` 26 | However, this does not work since `math.random` is called once *per layer* and returns a different value each time, so the two layers will shake independently from each other. 27 | 28 | One way to fix this is to pregenerate all random values ahead of time, i.e. to have a `code line` that creates a table of one random value per frame. 29 | This works, but it's quite cumbersome and gets more and more annoying the more loops or random values you use. 30 | 31 | A simpler way is to use `math.randomseed` in each layer to make the random generator deterministic: 32 | ```lua 33 | Comment: 0,[...],template line,{\bord4\shad0\1a&HFF&} 34 | Comment: 1,[...],template line,{\bord0\shad0} 35 | Comment: 0,[...],mixin line ,!util.fbf()!!math.randomseed(loopctx.state.fbf)!{\blur2\an5\pos(!orgline.center + math.random(-10,10)!,!orgline.middle + math.random(-10,10)!)} 36 | ``` 37 | Here, it's important to make the seed depend on the current frame number `loopctx.state.fbf` so that the random values are actually different every frame. 38 | 39 | However, this still has a problem: 40 | Now, the random seed *only* depends on the current frame. 41 | This means that it *doesn't* depend on the karaoke line, so every line will shake in the same way. 42 | This may not be too visible for this effect, but for other effects that use random values in different ways it can be much more apparent. 43 | You can work around this with more tricks (like instead using `line.start_time` as a seed), but the following function abstracts this away quite nicely:[^hash] 44 | ```lua 45 | Comment: [...],code once,local seed = 1234; local p = 2 ^ 31 - 1; function setseed(...) local v = tostring(seed); for _,x in _G.ipairs({...}) do v = v .. "|" .. tostring(x):gsub("\\", "\\\\"):gsub("|", "\\|") end local s=1; for i = 1,#v do s = (s * (string.byte(v, i) + 256 * i)) % p end; math.randomseed(s); end 46 | ``` 47 | By changing the `seed` variable at the beginning you can shuffle all the seeds for random variables generated in this way. 48 | 49 | [^hash]: This uses a very simple and stupid hash function to turn all dependencies into a single numerical seed. I do not pretend that this is a *good* hash function, the main goal was to write something simple that fits in one line without any dependencies. 50 | 51 | Now, you can use this function in place of `math.randomseed` as follows: 52 | ```lua 53 | Comment: 0,[...],template line,{\bord4\shad0\1a&HFF&} 54 | Comment: 1,[...],template line,{\bord0\shad0} 55 | Comment: 0,[...],mixin line ,!util.fbf()!!setseed(orgline.raw, loopctx.state.fbf)!{\blur2\an5\pos(!orgline.center + math.random(-10,10)!,!orgline.middle + math.random(-10,10)!)} 56 | ``` 57 | As arguments to `setseed` you should pass all the values that the random values should depend on. 58 | Hence, we pass `orgline.raw` and `loopctx.state.fbf` here to make different lines and different frames have different values, but we do not pass `line.layer`, since we want both layers to have the same random values. 59 | 60 | You can pass any Lua value as a dependency, but note that all dependencies are converted to strings via `tostring`, so tables are effectively checked for referential equality rather than equality of elements. 61 | 62 | There are a few other ways to achieve consistent random values like this (e.g. implementing your own stateless PRNG or using a lazily initialized dictionary of random values), but this method has the advantage that you can transparently use any existing functions that make use of `math.random` like `random` (see above), the `util.rand.*` functions in 0x's templater, and so on. 63 | However, one caveat for this method is that the number and order of calls to `math.random()` needs to be the same in every iteration: 64 | You cannot call `math.random()` on one layer and not call it on the other or the random states will go out of sync. 65 | If you find yourself needing to do this, either call `math.random()` unconditionally and discard the result where you don't need it, or call `setseed` again after your conditional calls are finished. 66 | 67 | ### A Dummy Function to Eat Values 68 | Put `function d() return "" end` into a `code once` to get a function `d` that can be used to eat any value. 69 | This can be useful when some `!!` expression (e.g. something involving `or` or `and`) evaluates to a boolean or some other string, which then ends up being written into your generated line without you wanting to. 70 | By just wrapping that expression in a `d()`, you can discard the value. 71 | 72 | This is also useful if you want to write comments inside of `template` or `mixin` lines, since you can just do `!d("This is a comment")!`. 73 | 74 | ### `set` is cool 75 | `set` is a very helpful function and you should use it. 76 | As a reminder, it can be used inside of `template` or `mixin` lines to remember values. 77 | So, instead of `{\fscx!100 + 2*t!\fscy!100 + 2*t!}` you can write `!set("scale", 100 + 2 * t)!{\fscx!scale!\fscy!scale!}`. 78 | In general, it is good practice to never have to write any constant (at least any constant you may want to modify later) or any formula multiple times. 79 | Using `set` can help you with that. 80 | 81 | You can also sometimes use a `code line/syl/word/char` instead of `set`, but, as a reminder, these only run *once* for every line/syl/word/char in the input lyrics, while `set` runs for every *generated* line (i.e. also for every loop iteration). 82 | Hence, if you need to set a value depending on the loop iteration (or your fbf frame), you need to use `set` rather than some `code` line. 83 | 84 | ### Inline If-and-Else 85 | In Lua, you can write an inline if-and-else expression (i.e. a ternary operator) using `and` and `or`, for example: `!orgline.actor == "big" and 150 or 100!` evaluates to `150` if `orgline.actor` is `"big"` and to `100` otherwise. 86 | 87 | If you want to use this to conditionally call a function (e.g. `!line.end_time < line.start_time and skip()!`), note that this expression can evaluate to `false` and end up writing `"false"` into your generated line. 88 | Hence, you may want to wrap such expressions in a `d()` (see [above](#a-dummy-function-to-eat-values)). 89 | 90 | ### Immediately-Invoked Function Expressions (IIFE) 91 | If you really need to run complex code (e.g. including loops) inside of a `template` or `mixin` and can't/don't want to use a `code` line or a helper function, you can use an IIFE with `!(function () end)()!`. 92 | 93 | ### Easy `\pos` 94 | If you have KaraOK installed (but note that you'll still run this template using The0x's templater), you can use `!ln.tag.pos()!` as a shorthand for quickly setting your syllable's position (i.e. for something like `\an5\pos(!orgline.left + syl.center!,!orgline.middle!)`). 95 | You can customize the alignment or add a shift using parameters to this function, refer to the [KaraOK documentation](https://github.com/logarrhythmic/karaOK?tab=readme-ov-file#pos) for more information. 96 | 97 | ### Getting the Relative Time with FBF 98 | When you use `util.fbf` to get a frame-by-frame loop, you can use `!set("t", line.start_time - orgline.start_time)!` to then find the relative time from the current frame slice to the start of the line. 99 | 100 | If you additionally want to retime everything to the syllable/char or add lead-in, you can e.g. use the following pattern: 101 | 102 | `!retime()!!set("starttime", line.start_time)!!util.fbf()!set("t", line.start_time - starttime)!`. 103 | 104 | Using this, you can then use transforms in your line by just subtracting `t` from all values: A `\t(0,150,\frz30)` on a non-fbf'd line can turn into a `\t(!-t!,!150 - t!,\frz30)` on an fbf'd line. 105 | This saves you the trouble of writing your own interpolation functions and baking in the values (though you could do this too, e.g. using `util.lerp`). 106 | 107 | ### ASS Gotcha: `\move` with Relative Times 108 | If you do the above with `\move` rather than with `\t` you should be aware that a `\move` whose start and end timestamps are negative (or zero) results in a move over the entire line's duration. 109 | Hence, for `\move` you may need to add some additional logic or to use `util.lerp` with a `\pos` instead. 110 | 111 | ### Easy Alternating Values with `(-1)^i` 112 | If you want a value to flip between positive and negative every syl/char/line/etc, you can multiply it with `(-1)^syl.i`. 113 | This is much more compact than manual code or something like `syl.i % 2 == 0 and value or -value`. 114 | 115 | ### Skipping Negative-Duration Lines 116 | When you do a lot of looping and retiming you may end up with some lines that have negative duration, i.e. whose start time ends up being larger than their end time. 117 | To avoid this, you can add a `!d(line.start_time > line.end_time and skip())!` after all retimes (e.g. in a separate `mixin`). 118 | 119 | As a reminder, this works because `line` is the *generated* line, as opposed to `orgline` which is your *input* line. 120 | Calls to `retime` affect `line.start_time` and `line.end_time`, but not `orgline.start_time` and `orgline.end_time`. 121 | 122 | ### Clamping Line Times 123 | When (for example) you have some per-syllable highlight effect that lasts longer than the syllable itself (e.g. some highlight color that fades out), you may not want it to last longer than your entire line. 124 | Even if you want your highlights to last longer than the line they belong to, you may want them to stop before the *following* line starts. 125 | To enforce this, you can use snippets like `!d(retime("abs", line.start_time, math.min(line.end_time, orgline.end_time)))!` to clamp the end time to the line's end time, 126 | or `!d(orgline.next and orgline.next.start_time > orgline.start_time and retime("abs", line.start_time, math.min(line.end_time, orgline.next.start_time)))!` to clamp the end time to the following line's start time. 127 | If your lines have lead-in, this needs to be accounted for in the `retime` call. 128 | Of course, you can do similar things for start times. 129 | 130 | ### Text to Shape 131 | Use [ILL](https://github.com/TypesettingTools/ILL-Aegisub-Scripts/) to convert text to shapes and modify those shapes: 132 | - Make sure you have the `ILL` module installed via DependencyControl 133 | - Add a `code once` with `ill = require("ILL.ILL")` 134 | - Use `ill.Line.toPath({data=orgline.styleref,text_stripped=syl.text_stripped})` to obtain a path object for your text. For example, you could assign this to a variable `path` in a `code syl`, or use `set` (or use the result directly). You can `syl.text_stripped` with `orgline.text_stripped` or any other text you want. 135 | - To turn the path object into an actual shape, use `path:export()`. For example, you could use `{!ln.tag.pos(7)!\p1}!path:export()!`. 136 | - You can use various functions to modify the path, e.g. `path:move(10, 20)` to move the path around, `path:rotatefrz(90)` to rotate, etc. Refer to [the ILL source code](https://github.com/TypesettingTools/ILL-Aegisub-Scripts/blob/main/modules/ILL/ILL/Ass/Shape/Path.moon) for a list of functions. Note that functions like `move` and `rotatefrz` both return the modified path *and* modify the path they were called on. Hence, if you need to output multiple copies of the path that are modified in different ways, you may need to use `path:clone()` to clone your paths. 137 | 138 | ### Using Dummy `kara` Lines as Input 139 | Sometimes, you may want to hardcode some drawings or clips in your template. 140 | To make adding and modifying drawings as easy as possible, I like to edit these drawings as clips using Aegisub's built-in clipping tool. 141 | You can use the following system to directly read in these values: 142 | 143 | - Make a new style called `Dummy`. 144 | - Make sure you have imported ILL (see above). 145 | - Add a couple of lines with the style `Dummy` and the actor `kara` and give them clips using Aegisub's clip tool. Then, comment out the lines. If you want to edit them, you can uncomment them again, edit the clips, and comment them again. 146 | 147 | Make sure that these lines are *above* your normal `kara` lines. 148 | - Then, add a `code once` with the style `Dummy` and the code `shapes = {}`. 149 | - Also, add a `code line` with the style `Dummy` and the code `table.insert(shapes, ill.Path(orgline.text:match("%\\clip%(.-)%)")))`. 150 | - Then, you can access the list `shapes` of all the shapes in your remaining template's code (and e.g. export it using `shapes[0].export()`). 151 | 152 | If you want, you can also modify these shapes somehow (e.g. center them) or add extra metadata (e.g. the line's start and end times). 153 | If you only need the shape text (e.g. for a clip) and don't need to modify it in any way, you also don't need to go through `ill.Path()`. 154 | 155 | ### Importing DependencyControl-only Modules 156 | If, for some reason, you need to import some module that unconditionally calls DependencyControl (the most likely one being `Functional`), you need to set a `script_namespace` first: `_G.script_namespace = "mykaratemplate"; functional = require"l0.Functional";` 157 | 158 | ## Crimes 159 | Finally, here are some reasons why I shouldn't be allowed near a templater. 160 | 161 | ### Moonscript 162 | If you're too lazy to write a for loop or want to use moonscript's string interpolation, you can use the following snippet: 163 | 164 | - Add `code once` with `moon = require("moonscript.base"); function moony(s) return _G.setfenv(moon.loadstring(s), tenv)() end` 165 | - Then, you can define functions in moonscript like this: `double = moony("l -> [ a * 2 for a in *l ]")`. 166 | 167 | Of course, with moonscript using whitespace for indentation you can't easily write more complex logic in moonscript, but this *can* be useful for a quick list comprehension. 168 | 169 | ### Multiline Functions 170 | *This is mostly a meme. I haven't (yet) used this in a template myself. If you get to the point where you need to do this you should probably just write a separate file with utility functions or use something like [aegsc](https://github.com/butterfansubs/aegsc).* 171 | 172 | If you're sick of huge unreadable one-line Lua functions, you can do the following: 173 | 174 | - Add a `code once` with `multiline = {}; local last_actor = ""; for i,line in _G.ipairs(subs) do if line.effect == "multiline" then local actor = line.actor ~= "" and line.actor or multiline_last_actor; multiline_last_actor = actor; multiline[actor] = multiline[actor] and (multiline[actor] .. "\n" .. line.text) or line.text; end end` (yes, the irony of this being a huge unreadable one-line Lua function is not lost on me). 175 | - Write some multiline Lua code by adding multiple comments with the effect `multiline`. Give the first of these lines some name in the `actor`. 176 | ```lua 177 | Comment: [...],mycode,0,0,0,multiline,function myfun() 178 | Comment: [...] ,,0,0,0,multiline, -- Your code here 179 | Comment: [...] ,,0,0,0,multiline,end 180 | - Call that multiline code using `_G.setfenv(_G.loadstring(multiline.mycode), tenv)()`. 181 | For example, to define the function in the above example, add a `code once` with `_G.setfenv(_G.loadstring(multiline.mycode), tenv)()`. 182 | -------------------------------------------------------------------------------- /macros/arch.CenterTimes.lua: -------------------------------------------------------------------------------- 1 | script_name = "Center Times" 2 | script_author = "arch1t3cht" 3 | script_version = "1.1" 4 | script_namespace = "arch.CenterTimes" 5 | script_description = "Centering times of lines in frame ranges" 6 | 7 | ---------- Rationale behind this script: ---------- 8 | -- ** Motivation 9 | -- Some third-party tools like SubKt or various automation scripts can only 10 | -- shift subtitle lines by times, and not by frames. 11 | -- More specifically, some of them provide features for syncing two files 12 | -- with respect to two sync points by adding the time difference of these two 13 | -- sync points to every line in one of the files. 14 | -- 15 | -- .ass files specify times with centisecond accuracy, and one video frame 16 | -- usually spans more than one centisecond step. If the times of the sync 17 | -- points are aligned inside their frame range in an unfortunate manner, 18 | -- i.e. if one sync point has a time very early in its frame, while the 19 | -- other lies very late in its frame, the time difference will not come 20 | -- out to be a clean multiple of a frame's duration. If this is then added 21 | -- to the time of another line, the resulting number of frames this line 22 | -- is shifted by will depend on the alignment of the line's time in its frame. 23 | -- Thus, not all lines might be shifted by the same number of frames, which 24 | -- can cause 1-frame (or potentially worse) flashes. 25 | -- 26 | -- ** Possible remedies 27 | -- Of course, this can be solved by instead configuring such tools to shift 28 | -- by frames. However, this requires knowing the frame rate, which is not 29 | -- always nontrivial to code (as is the case with SubKt). 30 | -- In some cases, this issue can also be prevented by making sure that 31 | -- the times of all lines are aligned in a way that makes these frame-jumps 32 | -- mathematically impossible. Whether this is possible depends on the video's 33 | -- frame rate, but for common frame rates like 23.976 fps, this can be done: 34 | -- 35 | -- ** Computations 36 | -- - Without any rounding, for a frame rate of 23.976 fps, 37 | -- two frames are just over 41ms apart 38 | -- - .ass subtitles allow specifying times up to timesteps of 10ms 39 | -- - We can model this as if any line had an "ideal time" where it should 40 | -- actually be, which we always choose to be the exact midpoint of the time 41 | -- span of a frame, 42 | -- and the "approximated time", which is its time in any .ass file, and which 43 | -- is thus rounded to 10ms steps 44 | -- 45 | -- -> thus, when reading a line's time from a .ass file, ideally the absolute 46 | -- error for any given line is <= 5ms 47 | -- - When SubKt syncs a .ass line into another file, it 48 | -- - reads the sync target time a (error <= 5ms) 49 | -- - reads the sync source time b (error <= 5ms) 50 | -- - reads the actual line's time t (error <= 5ms) 51 | -- - and then times the line to t + (a - b). 52 | -- - Hence the maximum absolute error for the synced line is 53 | -- 5ms + 5ms + 5ms = 15ms. 54 | -- But this is smaller than 41ms / 2, so as long as these three times are 55 | -- close enough to their respective ideal times, the synced time will still 56 | -- be inside the 41ms range. 57 | -- 58 | -- ** Conclusion 59 | -- By timing each involved line to the centisecond step closest to the ideal 60 | -- time of the respective frame, one can ensure that every line stays inside 61 | -- its frame throughout such a syncing process for such a frame rate (and in 62 | -- fact for any constant frame rate below 33 fps). However, this will of 63 | -- course only work for one iteration of such a process, unless the times are 64 | -- recentered again after the first iteration. 65 | -- 66 | -- Aegisub does not time lines optimally, even when using functions that time 67 | -- lines to video frames. This script optimally centers the times of all 68 | -- selected lines with respect to (a very close approximation of) the currently 69 | -- loaded video's frame rate. It contains a built-in guarantee that no line 70 | -- will be moved to different frames (as reported by Aegisub) - if the computed 71 | -- time for any line lies in a different frame, the script will abort. 72 | -- Note, however, that this will (obviously) not apply for lines containing 73 | -- transform or \move tags. 74 | -- 75 | -- Floating point rounding errors are a theoretical concern, but (as long as 76 | -- floating point precision of 32 bit or more are used) will not be anywhere 77 | -- close to relevant for video lengths anywhere under 5 hours. 78 | -- 79 | -- ** Important Caveats 80 | -- This script (and, in fact, this whole argument) will NOT work whenever lines 81 | -- timed to the 0-th frame are involved, as 00:00:00.00 is the only centisecond 82 | -- step contained in this frame. 83 | -- 84 | -- It will also (obviously) not work for variable frame rates. Finally, the 85 | -- script assumes that the first frame starts right after 00:00:00.00, so 86 | -- it will not work for videos that don't conform to this. 87 | -- 88 | -- Finally, this script only changes the beginning and end times of subtitle lines, 89 | -- so the rendering of any lines containing \move or \t tags *will* be affected. 90 | 91 | 92 | function centertime(time, framerate) 93 | -- time: in milliseconds 94 | local frame = aegisub.frame_from_ms(time) 95 | local center = math.floor((frame - 0.5) / (framerate * 10) + 0.5) * 10 96 | 97 | if center < 0 then center = 0 end 98 | 99 | if aegisub.frame_from_ms(center) ~= frame then 100 | show_dialog("Assertion failed!") 101 | return nil 102 | end 103 | 104 | return center 105 | end 106 | 107 | function centertimes(subs, sel) 108 | -- use evil hacks to get the framerate 109 | local ref_ms = 100000000 -- 10^8 ms ~~ 27.7h 110 | local ref_frame = aegisub.frame_from_ms(ref_ms) 111 | 112 | if ref_frame == nil then 113 | aegisub.log("No video opened / no framerate available!") 114 | return 115 | end 116 | 117 | local framerate = ref_frame / ref_ms -- in frames/ms 118 | 119 | for x, i in ipairs(sel) do 120 | local line = subs[i] 121 | line.start_time = centertime(line.start_time, framerate) 122 | line.end_time = centertime(line.end_time, framerate) 123 | if line.start_time == nil or line.end_time == nil then 124 | -- assertion failed: error 125 | return 126 | end 127 | subs[i] = line 128 | end 129 | end 130 | 131 | function has_video(subs, sel) 132 | if aegisub.frame_from_ms(0) == nil then 133 | return false, "No video opened / no framerate available!" 134 | end 135 | return true 136 | end 137 | 138 | aegisub.register_macro(script_name,script_description,centertimes,has_video) -------------------------------------------------------------------------------- /macros/arch.ConvertFolds.moon: -------------------------------------------------------------------------------- 1 | export script_name = "Convert Folds" 2 | export script_description = "Convert folds stored in the project properties to extradata folds." 3 | export script_author = "arch1t3cht" 4 | export script_namespace = "arch.ConvertFolds" 5 | export script_version = "1.1.2" 6 | 7 | haveDepCtrl, DependencyControl = pcall(require, "l0.DependencyControl") 8 | 9 | local depctrl 10 | 11 | if haveDepCtrl 12 | depctrl = DependencyControl({ 13 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 14 | {}, 15 | }) 16 | 17 | 18 | folds_key = "_aegi_folddata" 19 | 20 | 21 | parse_line_fold = (line) -> 22 | return if not line.extra 23 | 24 | info = line.extra[folds_key] 25 | return if not info 26 | 27 | side, collapsed, id = info\match("^(%d+);(%d+);(%d+)$") 28 | return {:side, :collapsed, :id} 29 | 30 | 31 | load_folds = (subs, sel) -> 32 | apply = "Apply" 33 | fromfile = "From File" 34 | cancel = "Cancel" 35 | 36 | button, results = aegisub.dialog.display({{ 37 | class: "label", 38 | label: "Paste the \"Line Folds\" line from the Project Properties:", 39 | x: 0, y: 0, width: 1, height: 1, 40 | },{ 41 | class: "edit", 42 | name: "foldinfo" 43 | x: 0, y: 1, width: 1, height: 1, 44 | }}, {apply, fromfile, cancel}, {"ok": apply, "cancel": cancel}) 45 | 46 | return if not button 47 | 48 | foldinfo = results.foldinfo 49 | 50 | if button == fromfile 51 | f = io.open(aegisub.decode_path("?script/#{aegisub.file_name()}")) 52 | if f == nil 53 | aegisub.log("Couldn't open subtitle file.\n") 54 | aegisub.cancel() 55 | 56 | content = f\read("a")\gsub("\r\n", "\n") 57 | f\close() 58 | infoline = content\match("\n(Line Folds: *[0-9:,]* *\n)") 59 | if infoline == nil 60 | aegisub.log("Couldn't find fold info in subtitle file.\n") 61 | aegisub.cancel() 62 | 63 | foldinfo = infoline\gsub("^\n*", "")\gsub("\n*$", "") 64 | 65 | 66 | maxid = 0 67 | local dialoguestart 68 | for i, line in ipairs(subs) 69 | fold = parse_line_fold(line) 70 | maxid = math.max(maxid, fold.id or 0) if fold 71 | 72 | if dialoguestart == nil and line.class == "dialogue" 73 | dialoguestart = i 74 | 75 | foldinfo = foldinfo\gsub("^Line Folds:", "")\gsub("^ *", "")\gsub(" *$", "") 76 | 77 | for foldrange in foldinfo\gmatch("[^,]+") 78 | maxid += 1 79 | 80 | fr, to, collapsed = foldrange\match("^(%d+):(%d+):(%d+)$") 81 | fr += dialoguestart 82 | to += dialoguestart 83 | 84 | line1 = subs[fr] 85 | line1.extra or= {} 86 | line1.extra[folds_key] = "0;#{collapsed};#{maxid}" 87 | subs[fr] = line1 88 | 89 | line2 = subs[to] 90 | line2.extra or= {} 91 | line2.extra[folds_key] = "1;#{collapsed};#{maxid}" 92 | subs[to] = line2 93 | 94 | 95 | wrap_register_macro = (...) -> 96 | if haveDepCtrl 97 | depctrl\registerMacro(...) 98 | else 99 | aegisub.register_macro(script_name, script_description) 100 | 101 | wrap_register_macro(load_folds) 102 | -------------------------------------------------------------------------------- /macros/arch.DerivePerspTrack.moon: -------------------------------------------------------------------------------- 1 | export script_name = "Derive Perspective Track" 2 | export script_description = "Create a power-pin track file from the outer perspective quads of a set of lines." 3 | export script_author = "arch1t3cht" 4 | export script_namespace = "arch.DerivePerspTrack" 5 | export script_version = "1.1.2" 6 | 7 | DependencyControl = require("l0.DependencyControl") 8 | dep = DependencyControl{ 9 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 10 | { 11 | {"l0.Functional", version: "0.6.0", url: "https://github.com/TypesettingTools/Functional", 12 | feed: "https://raw.githubusercontent.com/TypesettingTools/Functional/master/DependencyControl.json"}, 13 | {"a-mo.LineCollection", version: "1.3.0", url: "https://github.com/TypesettingTools/Aegisub-Motion", 14 | feed: "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json"}, 15 | {"l0.ASSFoundation", version: "0.5.0", url: "https://github.com/TypesettingTools/ASSFoundation", 16 | feed: "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 17 | {"arch.Math", version: "0.1.10", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 18 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 19 | {"arch.Perspective", version: "0.2.3", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 20 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 21 | "karaskel", 22 | } 23 | } 24 | 25 | Functional, LineCollection, ASS, AMath, APersp = dep\requireModules! 26 | {:Point} = AMath 27 | {:prepareForPerspective, :transformPoints, :Quad} = APersp 28 | 29 | 30 | outer_quad_key = "_aegi_perspective_ambient_plane" 31 | translate_outer_powerpin = {1, 2, 4, 3} 32 | 33 | 34 | get_outer_quad = (subs, i) -> 35 | quadinfo = subs[i].extra[outer_quad_key] 36 | return nil if quadinfo == nil 37 | 38 | x1, y1, x2, y2, x3, y3, x4, y4 = quadinfo\match("^([%d-.]+);([%d-.]+)|([%d-.]+);([%d-.]+)|([%d-.]+);([%d-.]+)|([%d-.]+);([%d-.]+)$") 39 | return nil if x1 == nil 40 | 41 | return Quad({{x1, y1}, {x2, y2}, {x3, y3}, {x4, y4}}) 42 | 43 | 44 | get_quad_from_tags = (subs, i) -> 45 | -- We need to go through LineCollection here to get the styleRef 46 | lines = LineCollection subs, {i} 47 | 48 | data = ASS\parse(lines.lines[1]) 49 | tags, width, height, warnings = prepareForPerspective(ASS, data) 50 | 51 | for warn in *warnings 52 | if warn[1] == "move" 53 | aegisub.log("Failed to derive: line has \\move!") 54 | aegisub.cancel() 55 | -- ignore the other ones for now 56 | 57 | return transformPoints(tags, width, height) 58 | 59 | 60 | derive_persp_track = (derive_fun) -> (subs, sel) -> 61 | meta = karaskel.collect_head subs, false 62 | quads = {} 63 | 64 | for li in *sel 65 | line = subs[li] 66 | q = derive_fun(subs, li) 67 | if q == nil 68 | aegisub.log("Selected line has no outer quad set!") 69 | aegisub.cancel() 70 | 71 | sf = aegisub.frame_from_ms(line.start_time) 72 | ef = aegisub.frame_from_ms(line.end_time) - 1 73 | 74 | for f=sf,ef 75 | if quads[f] != nil 76 | aegisub.log("Selected lines have overlapping times!") 77 | aegisub.cancel() 78 | 79 | quads[f] = q 80 | 81 | minf = Point(Functional.table.keys(quads))\min() 82 | maxf = Point(Functional.table.keys(quads))\max() 83 | 84 | powerpin = {} 85 | append = (s) -> table.insert powerpin, s 86 | 87 | append "Adobe After Effects 6.0 Keyframe Data" 88 | append "" 89 | append "\tUnits Per Second\t23.976" 90 | append "\tSource Width\t#{meta.res_x}" 91 | append "\tSource Height\t#{meta.res_y}" 92 | append "\tSource Pixel Aspect Ratio\t1" 93 | append "\tComp Pixel Aspect Ratio\t1" 94 | append "" 95 | 96 | for i=1,4 97 | append "Effects\tCC Power Pin #1\tCC Power Pin-000#{i+1}" 98 | append "\tFrame\tX pixels\tY pixels" 99 | j = translate_outer_powerpin[i] 100 | 101 | q = quads[minf] 102 | for f=minf,maxf 103 | q = quads[f] unless quads[f] == nil 104 | append "\t#{f - minf}\t#{q[j][1]}\t#{q[j][2]}" 105 | 106 | append "" 107 | 108 | append "End of Keyframe Data" 109 | 110 | aegisub.log(table.concat powerpin, "\n") 111 | 112 | 113 | dep\registerMacros { 114 | {"From Outer Quad", "Derive a Power-Pin track from the outer quad set using the perspective tool", derive_persp_track get_outer_quad} 115 | {"From Tags", "Derive a Power-Pin track from the override tags of the selected lines", derive_persp_track get_quad_from_tags} 116 | } 117 | -------------------------------------------------------------------------------- /macros/arch.FocusLines.moon: -------------------------------------------------------------------------------- 1 | export script_name = "Focus Lines" 2 | export script_description = "Draws moving focus lines." 3 | export script_version = "1.0.1" 4 | export script_namespace = "arch.FocusLines" 5 | export script_author = "arch1t3cht" 6 | 7 | haveDepCtrl, DependencyControl = pcall(require, "l0.DependencyControl") 8 | local AMath 9 | local depctrl 10 | 11 | if haveDepCtrl 12 | depctrl = DependencyControl({ 13 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 14 | { 15 | {"arch.Math", version: "0.1.0", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 16 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 17 | } 18 | }) 19 | AMath = depctrl\requireModules! 20 | else 21 | AMath = require "arch.Math" 22 | 23 | {:Point, :Matrix} = AMath 24 | 25 | screenwidth = 0 26 | screenheight = 0 27 | 28 | round = (val, n=0) -> math.floor((val * 10^n) + 0.5) / (10^n) 29 | 30 | randrange = (a, b) -> a + (b - a) * math.random() 31 | 32 | format_drawing = (m) -> 33 | s = "m" 34 | for i, p in ipairs(m) 35 | if i == 2 36 | s ..= " l" 37 | s ..= " #{round(p[1], 2)} #{round(p[2], 2)}" 38 | return s 39 | 40 | 41 | draw_focus_lines_at_frame = (orgline, i, results) -> 42 | orgline.start_time = aegisub.ms_from_frame(i) 43 | orgline.end_time = aegisub.ms_from_frame(i + 1) 44 | 45 | color = results.color\gsub("#(%x%x)(%x%x)(%x%x).*","&H%3%2%1&") 46 | 47 | outlines = {} 48 | 49 | extent = (screenwidth + screenheight) / 2 / math.min(results.width, results.height) 50 | 51 | for i=1,results.layers 52 | line = {k,v for k,v in pairs(orgline)} 53 | line.layer += i - 1 54 | 55 | t = (i - 1) / (results.layers - 1) 56 | blur = (1 - t) * results.minblur + t * results.maxblur 57 | 58 | text = "{\\an7\\p1\\1c#{color}\\bord0\\shad0\\blur#{round(blur, 2)}\\pos(#{screenwidth/2},#{screenheight/2})}" 59 | 60 | for j=1,results.numlines 61 | angle = math.random() * 2 * math.pi 62 | 63 | linewidth = randrange(results.minlinewidth, results.maxlinewidth) / 100 64 | 65 | shape = Matrix({ 66 | {1 + randrange(-results.sizechange, results.sizechange)/100, 0}, 67 | {1 + results.linepointiness/100 + randrange(-results.sizechange, results.sizechange)/100, linewidth}, 68 | {extent, linewidth}, 69 | {extent, -linewidth}, 70 | {1 + results.linepointiness/100 + randrange(-results.sizechange, results.sizechange)/100, -linewidth}, 71 | }) 72 | 73 | shape = shape * Matrix.rot2d(angle) 74 | shape = shape * Matrix({ 75 | {results.width / 2, 0}, 76 | {0, results.height / 2}, 77 | }) 78 | 79 | if j != 1 80 | text ..= " " 81 | 82 | text ..= format_drawing(shape) 83 | 84 | line.text = text 85 | table.insert(outlines, line) 86 | 87 | return outlines 88 | 89 | 90 | draw_focus_lines = (subs, sel, results) -> 91 | offset = 0 92 | newsel = {} 93 | 94 | for si, li in ipairs(sel) 95 | line = subs[li + offset] 96 | 97 | startframe = aegisub.frame_from_ms(line.start_time) 98 | endframe = aegisub.frame_from_ms(line.end_time) - 1 99 | 100 | for i=startframe,endframe 101 | for fline in *draw_focus_lines_at_frame(line, i, results) 102 | subs.insert(li + offset, fline) 103 | table.insert(newsel, li + offset) 104 | offset += 1 105 | 106 | subs.delete(li + offset) 107 | offset -= 1 108 | 109 | return newsel 110 | 111 | 112 | focus_lines = (subs, sel) -> 113 | screenwidth, screenheight = aegisub.video_size() 114 | 115 | ok = "OK" 116 | cancel = "Cancel" 117 | help = "Help" 118 | 119 | while true 120 | button, results = aegisub.dialog.display({{ 121 | class: "label", 122 | label: " Lines per Layer: " 123 | x: 0, y: 0, width: 2, height: 1, 124 | }, { 125 | class: "intedit", 126 | name: "numlines", 127 | min: 0, 128 | max: 1000, 129 | value: 40, 130 | x: 2, y: 0, width: 1, height: 1, 131 | }, { 132 | class: "label", 133 | label: "Layers: " 134 | x: 3, y: 0, width: 1, height: 1, 135 | }, { 136 | class: "intedit", 137 | name: "layers", 138 | min: 0, 139 | max: 50, 140 | value: 5, 141 | x: 4, y: 0, width: 1, height: 1, 142 | }, { 143 | class: "label", 144 | label: "Color: " 145 | x: 5, y: 0, width: 1, height: 1, 146 | }, { 147 | class: "color", 148 | name: "color", 149 | x: 6, y: 0, width: 1, height: 1, 150 | }, { 151 | class: "label", 152 | label: "Ellipse: " 153 | x: 0, y: 1, width: 1, height: 1, 154 | }, { 155 | class: "label", 156 | label: "Width: " 157 | x: 1, y: 1, width: 1, height: 1, 158 | }, { 159 | class: "floatedit", 160 | name: "width", 161 | min: 0, 162 | max: 2 * screenwidth, 163 | value: round(0.8 * screenwidth, -2), 164 | x: 2, y: 1, width: 1, height: 1, 165 | }, { 166 | class: "label", 167 | label: "Height: " 168 | x: 3, y: 1, width: 1, height: 1, 169 | }, { 170 | class: "floatedit", 171 | name: "height", 172 | min: 0, 173 | max: 2 * screenheight, 174 | value: round(0.7 * screenheight, -2), 175 | x: 4, y: 1, width: 1, height: 1, 176 | }, { 177 | class: "label", 178 | label: "Size Change (%): " 179 | x: 5, y: 1, width: 1, height: 1, 180 | }, { 181 | class: "floatedit", 182 | name: "sizechange", 183 | min: 0, 184 | max: 100, 185 | value: 10, 186 | x: 6, y: 1, width: 1, height: 1, 187 | }, { 188 | class: "label", 189 | label: "Blur: " 190 | x: 0, y: 2, width: 1, height: 1, 191 | }, { 192 | class: "label", 193 | label: "Min: " 194 | x: 1, y: 2, width: 1, height: 1, 195 | }, { 196 | class: "floatedit", 197 | name: "minblur", 198 | min: 0, 199 | max: 10, 200 | value: 5, 201 | x: 2, y: 2, width: 1, height: 1, 202 | }, { 203 | class: "label", 204 | label: "Max: " 205 | x: 3, y: 2, width: 1, height: 1, 206 | }, { 207 | class: "floatedit", 208 | name: "maxblur", 209 | min: 0, 210 | max: 10, 211 | value: 10, 212 | x: 4, y: 2, width: 1, height: 1, 213 | }, { 214 | class: "label", 215 | label: "Line Width (%): " 216 | x: 0, y: 3, width: 1, height: 1, 217 | }, { 218 | class: "label", 219 | label: "Min: " 220 | x: 1, y: 3, width: 1, height: 1, 221 | }, { 222 | class: "floatedit", 223 | name: "minlinewidth", 224 | min: 0, 225 | max: 100, 226 | value: 1, 227 | x: 2, y: 3, width: 1, height: 1, 228 | }, { 229 | class: "label", 230 | label: "Max: " 231 | x: 3, y: 3, width: 1, height: 1, 232 | }, { 233 | class: "floatedit", 234 | name: "maxlinewidth", 235 | min: 0, 236 | max: 100, 237 | value: 1, 238 | x: 4, y: 3, width: 1, height: 1, 239 | }, { 240 | class: "label", 241 | label: "Line Pointiness (%): " 242 | x: 5, y: 3, width: 1, height: 1, 243 | }, { 244 | class: "floatedit", 245 | name: "linepointiness", 246 | min: 0, 247 | max: 100, 248 | value: 70, 249 | x: 6, y: 3, width: 1, height: 1, 250 | }}, {ok, help, cancel}, {ok: ok, cancel: cancel}) 251 | 252 | return draw_focus_lines(subs, sel, results) if button == ok 253 | break if button == cancel or button == false 254 | 255 | if button == help 256 | aegisub.dialog.display({{ 257 | class: "textbox", 258 | x: 0, y: 0, width: 50, height: 10, 259 | value: [[ 260 | This script replaces all selected subtitle lines with drawn frame-by-frame focus lines. 261 | The focus lines are drawn using multiple layers of shapes, with different layers having different blur - scaling 262 | linearly from the minimum blur to the maximum blur. 263 | All other values are independent of the layer. The line width is chosen randomly in the given range for each individual line. 264 | The ellipse's size change controls how much the focus lines randomly move inward and outward. 265 | The "line pointiness" option is probably the least useful one, but it controls where the thickest point of each line lies. 266 | ]], 267 | }}) 268 | 269 | 270 | has_video = () -> aegisub.ms_from_frame(0) != nil 271 | 272 | aegisub.register_macro(script_name, script_description, focus_lines, has_video) 273 | -------------------------------------------------------------------------------- /macros/arch.GitSigns.moon: -------------------------------------------------------------------------------- 1 | export script_name = "Git Signs" 2 | export script_description = "Displays git diffs in Aegisub" 3 | export script_version = "0.2.4" 4 | export script_namespace = "arch.GitSigns" 5 | export script_author = "arch1t3cht" 6 | 7 | haveDepCtrl, DependencyControl = pcall(require, "l0.DependencyControl") 8 | local config 9 | local fun 10 | local depctrl 11 | 12 | default_config = { 13 | git_path: "", 14 | } 15 | 16 | if haveDepCtrl 17 | depctrl = DependencyControl({ 18 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 19 | { 20 | {"l0.Functional", version: "0.6.0", url: "https://github.com/TypesettingTools/Functional", 21 | feed: "https://raw.githubusercontent.com/TypesettingTools/Functional/master/DependencyControl.json"}, 22 | } 23 | }) 24 | config = depctrl\getConfigHandler(default_config, "config", false) 25 | fun = depctrl\requireModules! 26 | else 27 | id = () -> nil 28 | config = {c: default_config, load: id, write: id} 29 | fun = require "l0.Functional" 30 | 31 | local has_git 32 | local current_diff 33 | 34 | 35 | get_git = () -> 36 | if config.c.git_path != "" then config.c.git_path else "git" 37 | 38 | 39 | check_has_git = () -> 40 | return has_git if has_git != nil 41 | 42 | handle = io.popen("#{get_git()} --help", "r") 43 | content = handle\read("*a") 44 | has_git = content != "" 45 | return has_git 46 | 47 | 48 | get_git_diff = (ref) -> 49 | dir = aegisub.decode_path("?script") 50 | if dir == "?script" 51 | aegisub.log("File isn't saved! Aborting.") 52 | aegisub.cancel() 53 | 54 | handle = io.popen("#{get_git()} -C \"#{dir}\" diff --raw -p \"#{ref}\" \"#{aegisub.file_name()}\"") 55 | return handle\read("*a") 56 | 57 | 58 | clear_markers = (subs) -> 59 | lines_to_delete = {} 60 | 61 | for si, line in ipairs(subs) 62 | continue if line.class != "dialogue" 63 | if line.effect\match("%[Git %-%]") 64 | table.insert(lines_to_delete, si) 65 | line.effect = line.effect\gsub("%[Git [^%]]*%]", "") 66 | subs[si] = line 67 | 68 | subs.delete(lines_to_delete) 69 | 70 | 71 | string2time = (timecode) -> 72 | timecode\gsub("(%d):(%d%d):(%d%d)%.(%d%d)", (a, b, c, d) -> d * 10 + c * 1000 + b * 60000 + a * 3600000) 73 | 74 | 75 | parse_ass_line = (str) -> 76 | ltype, layer, s_time, e_time, style, actor, margin_l, margin_r, margin_t, effect, text = str\match( 77 | "(%a+): (%d+),([^,]-),([^,]-),([^,]-),([^,]-),([^,]-),([^,]-),([^,]-),([^,]-),(.*)" 78 | ) 79 | return { 80 | class: "dialogue", 81 | comment: ltype == "Comment", 82 | start_time: string2time(s_time), 83 | end_time: string2time(e_time), 84 | :layer, 85 | :style, 86 | :margin_l, 87 | :margin_r, 88 | :margin_t, 89 | :actor, 90 | :effect, 91 | :text, 92 | extra: {}, 93 | } 94 | 95 | 96 | -- Returns a table (because I didn't want to figure out lua iterators) 97 | -- of index-line pairs, where the indexing skips lines marked as removed 98 | iterate_diff_section = (lines) -> 99 | reindexed = {} 100 | 101 | newindex = 1 102 | for gline in *lines 103 | table.insert(reindexed, {:newindex, :gline}) 104 | newindex += 1 unless gline\match("^%-") 105 | 106 | return reindexed 107 | 108 | 109 | show_diff_lines = (subs, diff, show_before) -> 110 | clear_markers(subs) 111 | parts = fun.string.split diff, "@@" 112 | sections = {} 113 | 114 | i = 2 115 | while i + 1 <= #parts 116 | oldfrom, oldto, newfrom, newto = parts[i]\match("%-([%d]+),([%d]+) %+([%d]+),([%d]+)") 117 | lines = fun.string.split parts[i+1]\sub(2), "\n" 118 | 119 | if oldfrom == nil or oldto == nil or newfrom == nil or newto == nil or lines == nil 120 | aegisub.log("Invalid diff output!\n") 121 | aegisub.cancel() 122 | 123 | table.insert(sections, {:oldfrom, :oldto, :newfrom, :newto, :lines}) 124 | i += 2 125 | 126 | local offset 127 | for i, section in ipairs(sections) 128 | for {:newindex, :gline} in *iterate_diff_section(section.lines) 129 | if newindex > 1 and gline\match("^%+?Dialogue: ") or gline\match("^%+?Comment: ") 130 | rl = gline\gsub("^%+", "")\gsub("\r$", "") 131 | 132 | aegisub.log(5, "Trying to find anchor line #{gl}\n") 133 | 134 | for si, s in ipairs(subs) 135 | if s.raw == rl 136 | offset = si - (section.newfrom + newindex - 1) 137 | aegisub.log(5, "Found offset #{offset}") 138 | break 139 | 140 | if offset == nil 141 | aegisub.log("Diff didn't match the subtitles! Make sure to save your file.\n") 142 | aegisub.cancel() 143 | 144 | break unless offset == nil 145 | break unless offset == nil 146 | 147 | if offset == nil 148 | aegisub.log("Either the diff didn't match the subtitles (save your file if so), or no dialogue lines were changed.\n") 149 | aegisub.cancel() 150 | 151 | lines_inserted = 0 152 | for i, section in ipairs(sections) 153 | for {:newindex, :gline} in *iterate_diff_section(section.lines) 154 | ind = section.newfrom + newindex - 1 + offset + lines_inserted 155 | 156 | if show_before and (gline\match("^%-Dialogue: ") or gline\match("^%-Comment: ")) 157 | newline = parse_ass_line(gline\sub(1)) 158 | newline.comment = true 159 | newline.effect = "[Git -]" .. newline.effect 160 | subs.insert(ind, newline) 161 | lines_inserted += 1 162 | 163 | if gline\match("^%+Dialogue: ") or gline\match("^%+Comment: ") 164 | line = subs[ind] 165 | line.effect = "[Git #{if show_before then '+' else '~'}]" .. line.effect 166 | subs[ind] = line 167 | 168 | 169 | show_diff_diag = (subs, sel) -> 170 | config\load() 171 | if not check_has_git() 172 | aegisub.log("Git executable not found!") 173 | aegisub.cancel() 174 | 175 | btn, result = aegisub.dialog.display({{ 176 | class: "label", 177 | label: "Target ref: ", 178 | x: 0, y: 0, width: 1, height: 1, 179 | },{ 180 | class: "edit", 181 | text: "HEAD", 182 | name: "ref", 183 | hint: [[The ref to diff with]], 184 | x: 1, y: 0, width: 1, height: 1, 185 | },{ 186 | class: "intedit", 187 | value: 0, 188 | min: 0, 189 | max: math.huge, 190 | name: "before", 191 | hint: [[How many commits to rewind from the ref. Added with a tilde after the ref.]], 192 | x: 2, y: 0, width: 1, height: 1, 193 | },{ 194 | class: "label", 195 | label: "Commits prior", 196 | x: 3, y: 0, width: 1, height: 1, 197 | },{ 198 | class: "checkbox", 199 | label: "Show before and after", 200 | hint: "Add commented lines marked [Git -] showing how the line looked before the change.", 201 | name: "show_before", 202 | value: config.c.show_before 203 | x: 2, y: 1, width: 2, height: 2, 204 | }}) 205 | 206 | return if not btn 207 | 208 | config.c.show_before = result.show_before if config.c.show_before != result.show_before 209 | config\write() 210 | 211 | ref = "#{result.ref}~#{result.before}" 212 | diff = get_git_diff(ref) 213 | 214 | current_diff = diff 215 | show_diff_lines(subs, diff, result.show_before) 216 | 217 | 218 | configure = () -> 219 | config\load() 220 | 221 | if not haveDepCtrl 222 | aegisub.log("DependencyControl wasn't found! The config will not be persistent.\n") 223 | 224 | ok = "Save" 225 | cancel = "Cancel" 226 | btn, result = aegisub.dialog.display({{ 227 | class: "label", 228 | label: "Git path: ", 229 | x: 0, y: 0, width: 1, height: 1, 230 | },{ 231 | class: "edit", 232 | name: "git_path", 233 | hint: "Path to git executable", 234 | value: config.c.git_path, 235 | x: 1, y: 0, width: 1, height: 1, 236 | }}, {ok, cancel}, {ok: ok, cancel: cancel}) 237 | 238 | return if not btn 239 | 240 | config.c.git_path = result.git_path if result.git_path != config.c.git_path 241 | config\write() 242 | 243 | 244 | mymacros = {} 245 | wrap_register_macro = (name, ...) -> 246 | if haveDepCtrl 247 | table.insert(mymacros, {name, ...}) 248 | else 249 | aegisub.register_macro("#{script_name}/#{name}", ...) 250 | 251 | wrap_register_macro("Show Diff", "Highlight the diff relative to a certain ref", show_diff_diag) 252 | wrap_register_macro("Configure", "Configure GitSigns", configure) 253 | wrap_register_macro("Clear Markers", "Clear GitSigns Markers", clear_markers) 254 | 255 | if haveDepCtrl 256 | depctrl\registerMacros(mymacros) 257 | -------------------------------------------------------------------------------- /macros/arch.Line2Fbf.moon: -------------------------------------------------------------------------------- 1 | export script_name = "FBF-ifier" -- thank Light for the name, I needed something that doesn't clash with Zeref's "Line To FBF" 2 | export script_description = "Convert lines into frame-by-frame chunks" 3 | export script_author = "arch1t3cht" 4 | export script_namespace = "arch.Line2Fbf" 5 | export script_version = "0.1.0" 6 | 7 | DependencyControl = require "l0.DependencyControl" 8 | dep = DependencyControl{ 9 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 10 | { 11 | {"a-mo.LineCollection", version: "1.3.0", url: "https://github.com/TypesettingTools/Aegisub-Motion", 12 | feed: "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json"}, 13 | {"l0.ASSFoundation", version: "0.5.0", url: "https://github.com/TypesettingTools/ASSFoundation", 14 | feed: "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 15 | {"arch.Util", version: "0.1.0", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 16 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 17 | } 18 | } 19 | LineCollection, ASS, Util = dep\requireModules! 20 | 21 | logger = dep\getLogger! 22 | 23 | fbfify = (subs, sel, active) -> 24 | lines = LineCollection subs, sel, () -> true 25 | 26 | to_delete = {} 27 | lines\runCallback ((lines, line) -> 28 | data = ASS\parse line 29 | 30 | table.insert to_delete, line 31 | 32 | fbf = Util.line2fbf data 33 | for fbfline in *fbf 34 | lines\addLine fbfline 35 | ), true 36 | 37 | lines\insertLines! 38 | lines\deleteLines to_delete 39 | 40 | dep\registerMacro fbfify 41 | 42 | -------------------------------------------------------------------------------- /macros/arch.NoteBrowser.moon: -------------------------------------------------------------------------------- 1 | export script_name = "Note Browser" 2 | export script_description = "Loads a set of timestamped notes and adds options to mark them or jump between them." 3 | export script_version = "1.3.6" 4 | export script_namespace = "arch.NoteBrowser" 5 | export script_author = "arch1t3cht" 6 | 7 | -- This script allows loading a collection of subtitle QC notes prefixed by timestamps, 8 | -- and allows navigation between mentioned lines in Aegisub. 9 | -- It's able to add the notes themselves to the lines, but it can also simply highlight 10 | -- the notes mentioned in the timestamps. Depending on the format of the notes, it could 11 | -- either be helpful to see them directly in Aegisub, or it could be too hard to navigate. 12 | -- In the latter case, the script could still save the time required to switch back and 13 | -- forth between Aegisub and the notes file and scroll to the mentioned line. 14 | -- 15 | -- Note format: 16 | -- A note is any line starting with a timestamp of the form hh:mm:ss or mm:ss . 17 | -- (As a consequence, lines starting with timestamps like 00:01:02.34 including centiseconds 18 | -- will also be recognized as a note, however the centiseconds will be ignored.) 19 | -- A note's text can be broken into multiple lines by indenting the following lines. 20 | -- 21 | -- More precisely, a note's text consists of all the following lines up until the first blank line 22 | -- with the property that the previous line is not indented. 23 | -- 24 | -- A file of notes can be organized into different sections (say, collecting notes on different 25 | -- topics or from different authors - the latter being the motivation for the macro names). 26 | -- A section is started by a line of the form [], where the 27 | -- section's name must not contain a closing bracket. 28 | -- 29 | -- Any text not matching one of these two formats is skipped. 30 | -- 31 | -- Furthermore, mpvQC files are transparently converted to the above format, provided the header is also included. 32 | -- 33 | -- Example: 34 | -- 35 | -- 0:01 - General note 1 36 | -- More explanation for that note 37 | -- 38 | -- Even more explanation 39 | -- 1:50 - General note 2 40 | -- 41 | -- [TLC] 42 | -- 3:24 - yabai should be translated as "terrible" instead. 43 | -- 44 | -- [Timing] 45 | -- 1:55 - Scene bleed 46 | -- 2:10 - Flashing subs 47 | -- 48 | -- Most of the script's functions should be self-explanatory with this. 49 | -- The one tricky element is "Jump to next/previous note by the same author": 50 | -- This will jump to the next note whose author is *the author of the last note 51 | -- the user jumped to using the ordinary "Jump to next/previous note" command. 52 | -- While this may seem counter-intuitive, this ensures that successively using 53 | -- "Jump to next/previous note by author" will indeed always jump to notes of the 54 | -- same author, even after arriving at a subtitle line with multiple notes on it. 55 | 56 | 57 | default_config = { 58 | mark: false, 59 | } 60 | 61 | haveDepCtrl, DependencyControl = pcall(require, "l0.DependencyControl") 62 | local config 63 | local fun 64 | local depctrl 65 | local clipboard 66 | 67 | if haveDepCtrl 68 | depctrl = DependencyControl({ 69 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 70 | { 71 | {"l0.Functional", version: "0.6.0", url: "https://github.com/TypesettingTools/Functional", 72 | feed: "https://raw.githubusercontent.com/TypesettingTools/Functional/master/DependencyControl.json"}, 73 | "aegisub.clipboard" 74 | } 75 | }) 76 | config = depctrl\getConfigHandler(default_config, "config", false) 77 | fun, clipboard = depctrl\requireModules! 78 | else 79 | id = () -> nil 80 | config = {c: default_config, load: id, write: id} 81 | fun = require "l0.Functional" 82 | clipboard = require "aegisub.clipboard" 83 | 84 | 85 | current_notes = {} 86 | notes_owners = {} 87 | current_all_notes = {} 88 | local current_author 89 | 90 | 91 | clear_markers = (subs) -> 92 | for si, line in ipairs(subs) 93 | continue if line.class != "dialogue" 94 | line.text = line.text\gsub("{|QC|[^}]+}", "")\gsub("- |QC|[^|]+|", "- OG") 95 | line.effect = line.effect\gsub("%[QC%-[^%]]*%]", "") 96 | subs[si] = line 97 | 98 | 99 | index_of_closest = (times, ms) -> 100 | local closest 101 | mindist = 15000 102 | 103 | for si, time in pairs(times) 104 | continue if time == nil 105 | diff = math.abs(time - ms) 106 | if diff < mindist 107 | mindist = diff 108 | closest = si 109 | 110 | return closest 111 | 112 | 113 | -- Joins lines with subsequent lines, until encountering a new line following an unindented line 114 | join_lines = (notelines) -> 115 | joined_lines = {} 116 | local currentline 117 | local lastindent 118 | for line in *notelines 119 | if currentline == nil 120 | currentline = line 121 | lastindent = "" 122 | else 123 | if line\match("^[%d]+:[%d]+") or line\match("^%[") or (line\match("^[%s]*$") and lastindent == "") 124 | table.insert(joined_lines, currentline) 125 | currentline = line 126 | lastindent = "" 127 | elseif not currentline\match("^[%s]*$") 128 | currentline ..= "\\N" .. line\gsub("^[%s]*", "") 129 | lastindent = currentline\match("^[%s]*") 130 | 131 | table.insert(joined_lines, currentline) unless currentline == nil 132 | 133 | return joined_lines 134 | 135 | 136 | patch_for_mpvqc = (lines) -> 137 | return lines unless #[true for line in *lines when line\match "^generator.*mpvQC"] > 0 138 | patched_lines = {} 139 | for line in *lines 140 | if line\match "^%[[%d:]+%]" 141 | section_header = line\match "^%[[^%]]+%] %[([^%]]+)%].*" 142 | qc = line\gsub("^%[([%d:]+)%] %[[^%]]-%](.*)$", "%1 -%2") 143 | table.insert(patched_lines, "[#{section_header}]") 144 | table.insert(patched_lines, qc) 145 | 146 | return patched_lines 147 | 148 | 149 | fetch_note_from_clipboard = -> 150 | note = clipboard.get! 151 | if note\match "%d+:%d+" 152 | return note 153 | return "" 154 | 155 | 156 | load_notes = (subs) -> 157 | config\load() 158 | btn, result = aegisub.dialog.display({{ 159 | class: "label", 160 | label: "Paste your QC notes here: ", 161 | x: 0, y: 0, width: 2, height: 1, 162 | },{ 163 | class: "checkbox", 164 | name: "mark", 165 | value: config.c.mark, 166 | label: "Mark lines with notes", 167 | x: 0, y: 1, width: 1, height: 1, 168 | },{ 169 | class: "checkbox", 170 | name: "inline", 171 | value: config.c.inline, 172 | label: "Show notes in line", 173 | x: 1, y: 1, width: 1, height: 1, 174 | },{ 175 | class: "textbox", 176 | name: "notes", 177 | text: fetch_note_from_clipboard!, 178 | x: 0, y: 2, width: 2, height: 10, 179 | }}) 180 | 181 | return if not btn 182 | 183 | notes = result.notes\gsub("\r\n", "\n") 184 | notelines = fun.string.split notes, "\n" 185 | notelines = patch_for_mpvqc notelines 186 | notelines = join_lines notelines 187 | 188 | current_section = "N" 189 | newnotes = {} 190 | report = {} 191 | 192 | for i, line in ipairs(notelines) 193 | newsection = line\match("^%[([^%]]*)%]$") 194 | if newsection 195 | current_section = newsection 196 | continue 197 | 198 | timestamp = line\match("^%d+:%d+:%d+") or line\match("^%d+:%d+") 199 | continue if not timestamp 200 | 201 | hours, minutes, seconds = line\match("^(%d+):(%d+):(%d+)") 202 | sminutes, sseconds = line\match("^(%d+):(%d+)") 203 | 204 | hours or= 0 205 | minutes or= sminutes 206 | seconds or= sseconds 207 | 208 | ms = 1000 * (3600 * hours + 60 * minutes + seconds) 209 | 210 | newnotes[current_section] or= {} 211 | table.insert(newnotes[current_section], ms) 212 | 213 | qc_report = line\match("^[%d:%s%.%-]+(.*)")\gsub("{", "[")\gsub("}", "]")\gsub("\\([^N])", "/%1") 214 | report[ms] or= {} 215 | table.insert(report[ms], qc_report) 216 | 217 | for k, v in pairs(newnotes) 218 | table.sort(v) 219 | 220 | current_notes = newnotes 221 | notes_owners = {} 222 | allnotes = {} 223 | for k, v in pairs(newnotes) 224 | for i, n in ipairs(v) 225 | notes_owners[n] or= {} 226 | table.insert(notes_owners[n], k) 227 | table.insert(allnotes, n) 228 | table.sort(allnotes) 229 | current_all_notes = allnotes 230 | current_author = nil 231 | 232 | clear_markers(subs) 233 | 234 | config.c.mark = result.mark 235 | config.c.inline = result.inline 236 | config\write() 237 | sections = fun.table.keys(current_notes) 238 | table.sort(sections) 239 | for i, section in ipairs(sections) 240 | for ni, ms in ipairs(current_notes[section]) 241 | si = index_of_closest({i,line.start_time for i, line in ipairs(subs) when line.class == "dialogue"}, ms) 242 | continue if not si 243 | line = subs[si] 244 | if result.inline 245 | for _, note in ipairs(report[ms]) 246 | line.text ..= "{|QC|#{note}|}" 247 | line.effect ..= "[QC-#{section}]" if result.mark 248 | subs[si] = line 249 | 250 | 251 | jump_to = (forward, same, subs, sel) -> 252 | if #current_all_notes == 0 253 | aegisub.log("No notes loaded!\n") 254 | aegisub.cancel() 255 | 256 | si = sel[1] 257 | 258 | pool = current_all_notes 259 | if same and current_author != nil 260 | pool = current_notes[current_author] 261 | 262 | -- we do this dynamically, since lines might have changed of shifted since loading the notes. 263 | subtitle_times = {i,line.start_time for i, line in ipairs(subs) when line.class == "dialogue"} 264 | lines_with_notes_rev = {} 265 | for _, n in ipairs pool 266 | closest_lines_with_notes = index_of_closest(subtitle_times, n) 267 | continue unless closest_lines_with_notes 268 | lines_with_notes_rev[closest_lines_with_notes] = n 269 | 270 | lines_with_notes = fun.table.keys lines_with_notes_rev 271 | 272 | -- yeeeeeah there are marginally faster algorithms, but that's not necessary at this scale. Let's keep it clean and simple. 273 | table.sort(lines_with_notes) 274 | for i=1,#lines_with_notes 275 | ind = if forward then i else #lines_with_notes + 1 - i 276 | comp = if forward then ((a,b) -> a > b) else ((a, b) -> a < b) 277 | new_si = lines_with_notes[ind] 278 | 279 | if comp(new_si, si) 280 | if not same 281 | -- if there are multiple notes on this line, pick one at random. It's very hard to think up a scenario 282 | -- where this would cause problems and not be easily avoidable. 283 | corresponding_note = lines_with_notes_rev[new_si] 284 | if corresponding_note != nil 285 | owners = notes_owners[corresponding_note] 286 | current_author = owners[1] if #owners >= 1 287 | return {new_si}, new_si 288 | 289 | 290 | mymacros = {} 291 | 292 | wrap_register_macro = (name, ...) -> 293 | if haveDepCtrl 294 | table.insert(mymacros, {name, ...}) 295 | else 296 | aegisub.register_macro("#{script_name}/#{name}", ...) 297 | 298 | wrap_register_macro("Load notes", "Load QC notes", load_notes) 299 | wrap_register_macro("Jump to next note", "Jump to the next note", (...) -> jump_to(true, false, ...)) 300 | wrap_register_macro("Jump to previous note", "Jump to the previous note", (...) -> jump_to(false, false, ...)) 301 | wrap_register_macro("Jump to next note by author", "Jump to the next note with the same author", (...) -> jump_to(true, true, ...)) 302 | wrap_register_macro("Jump to previous note by author", "Jump to the previous note with the same author", (...) -> jump_to(false, true, ...)) 303 | wrap_register_macro("Clear all markers", "Clear all the [QC-...] markers that were added when loading the notes", clear_markers) 304 | 305 | if haveDepCtrl 306 | depctrl\registerMacros(mymacros) 307 | -------------------------------------------------------------------------------- /macros/arch.PerspectiveMotion.moon: -------------------------------------------------------------------------------- 1 | export script_name = "Aegisub Perspective-Motion" 2 | export script_description = "Apply perspective motion tracking data" 3 | export script_author = "arch1t3cht" 4 | export script_namespace = "arch.PerspectiveMotion" 5 | export script_version = "0.3.0" 6 | 7 | DependencyControl = require "l0.DependencyControl" 8 | dep = DependencyControl{ 9 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 10 | { 11 | {"a-mo.LineCollection", version: "1.3.0", url: "https://github.com/TypesettingTools/Aegisub-Motion", 12 | feed: "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json"}, 13 | {"l0.ASSFoundation", version: "0.5.0", url: "https://github.com/TypesettingTools/ASSFoundation", 14 | feed: "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 15 | {"arch.Math", version: "0.1.10", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 16 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 17 | {"arch.Perspective", version: "1.2.0", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 18 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 19 | {"arch.Util", version: "0.1.0", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 20 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 21 | "aegisub.clipboard", 22 | } 23 | } 24 | LineCollection, ASS, AMath, APersp, Util, clipboard = dep\requireModules! 25 | {:Point, :Matrix} = AMath 26 | {:Quad, :an_xshift, :an_yshift, :relevantTags, :usedTags, :transformPoints, :tagsFromQuad, :prepareForPerspective} = APersp 27 | 28 | complained_about_layout_res = {} 29 | 30 | logger = dep\getLogger! 31 | 32 | die = (errmsg) -> 33 | aegisub.log(errmsg .. "\n") 34 | aegisub.cancel! 35 | 36 | 37 | track = (quads, options, subs, sel, active) -> 38 | lines = LineCollection subs, sel, () -> true 39 | videoW, videoH = aegisub.video_size! 40 | layoutScale = lines.meta.PlayResY / (lines.meta.LayoutResY or videoH) 41 | 42 | if layoutScale != 1 and not complained_about_layout_res[aegisub.file_name! or ""] 43 | complained_about_layout_res[aegisub.file_name! or ""] = true 44 | if lines.meta.LayoutResY 45 | aegisub.log("Your file's LayoutResY (#{lines.meta.LayoutResY}) does not match its PlayResY (#{lines.meta.PlayResY}). Unless you know what you're doing you should probably resample to make them match.") 46 | else 47 | aegisub.log("Your file's LPlayResY (#{lines.meta.PlayResY}) does not match your video's height (#{videoH}). You may want to set a LayoutResY for your file.") 48 | 49 | die("Invalid relative frame") if options.relframe < 1 or options.relframe > #quads 50 | 51 | abs_relframe = options.selection_start_frame + options.relframe - 1 52 | 53 | -- First, find out what lines should be transformed relative to which other lines 54 | frame2line = {} 55 | lines_intersect = false 56 | all_contain_relframe = true 57 | lines\runCallback (lines, line) -> 58 | all_contain_relframe and= (line.startFrame <= abs_relframe and abs_relframe < line.endFrame) 59 | for frame=line.startFrame,(line.endFrame - 1) 60 | lines_intersect = true if frame2line[frame] 61 | frame2line[frame] = line 62 | 63 | if lines_intersect and not all_contain_relframe 64 | die("Times of selected lines intersect but not all lines contain the reference frame. I don't know what to do with this. If you think of a way to make this script read the user's mind, let me know.") 65 | 66 | die("No line at reference frame!") if not frame2line[abs_relframe] 67 | 68 | rel_lines = {} 69 | local single_rel_line 70 | 71 | -- Then, FBF everything and find the lines we work relative to 72 | to_delete = {} 73 | lines\runCallback ((lines, line) -> 74 | data = ASS\parse line 75 | 76 | table.insert to_delete, line 77 | line.willdelete = true 78 | 79 | fbf = Util.line2fbf data 80 | for fbfline in *fbf 81 | lines\addLine fbfline 82 | 83 | if all_contain_relframe 84 | fbfline.rel_line = fbf[abs_relframe - line.startFrame + 1] 85 | rel_lines[fbfline.rel_line] = 1 86 | elseif fbfline.startFrame == abs_relframe 87 | single_rel_line = fbfline 88 | rel_lines[fbfline] = 1 89 | ), true 90 | 91 | rel_quad = quads[options.relframe] 92 | 93 | -- If we're supposed to apply the perspective, apply it to the relative lines 94 | if options.applyperspective 95 | for rel_line,_ in pairs(rel_lines) 96 | data = ASS\parse rel_line 97 | 98 | tagvals, width, height, warnings = prepareForPerspective(ASS, data) 99 | -- ignore the warnings because I'm lazy and this script isn't usually run unsupervised 100 | 101 | pos = Point(tagvals.position.x, tagvals.position.y) 102 | 103 | oldscale = { k,tagvals[k].value for k in *{"scale_x", "scale_y"} } 104 | 105 | -- Really, blindly applying perspective to some quad isn't a good idea (and not really necessary 106 | -- either now that there's a perspective tool), but some people want it. 107 | -- The problem is that it's not really clear what \fscx and \fscy should be, but I guess the 108 | -- most natural choice is just picking a perspective that does not change \fscx and \fscy 109 | -- (i.e. that keeps them at 100 if they weren't explicitly specified before). 110 | -- So the plan is to transform the line to the entire quad, see what \fscx and \fscy end up at, 111 | -- and use the inverses of those values to find the actual quad we want to transform to. 112 | 113 | data\removeTags relevantTags 114 | data\insertTags [ tagvals[k] for k in *usedTags ] 115 | 116 | rect_at_pos = (width, height) -> 117 | result = Quad.rect 1, 1 118 | result -= Point(an_xshift[tagvals.align.value], an_yshift[tagvals.align.value]) 119 | result *= (Matrix.diag(width, height)) 120 | result += rel_quad\xy_to_uv(pos) -- This breaks if the line already has some perspective but honestly if you run the script like that then that's on you 121 | result = Quad [ rel_quad\uv_to_xy(p) for p in *result ] 122 | return result 123 | 124 | tagsFromQuad(tagvals, rect_at_pos(1, 1), width, height, options.orgmode, layoutScale) 125 | 126 | tagsFromQuad(tagvals, rect_at_pos(oldscale.scale_x / tagvals.scale_x.value, oldscale.scale_y / tagvals.scale_y.value), width, height, options.orgmode, layoutScale) 127 | 128 | -- we don't need to adjust bord/shad since we're going for no change in scale 129 | 130 | data\cleanTags 4 131 | data\commit! 132 | 133 | -- Find some more data for the relative lines 134 | for rel_line,_ in pairs(rel_lines) 135 | data = ASS\parse rel_line 136 | rel_line_tags, width, height, warnings = prepareForPerspective(ASS, data) -- ignore warnings 137 | rel_line_quad = transformPoints(rel_line_tags, width, height, nil, layoutScale) 138 | 139 | rel_line.tags = rel_line_tags 140 | rel_line.quad = rel_line_quad 141 | 142 | -- Then, do the actual tracking 143 | lines\runCallback (lines, line) -> 144 | return if line.willdelete 145 | line.rel_line or= single_rel_line 146 | 147 | data = ASS\parse line 148 | frame_quad = quads[line.startFrame - lines.startFrame + 1] 149 | 150 | tagvals, width, height, warnings = prepareForPerspective(ASS, data) -- ignore warnings 151 | oldscale = { k,tagvals[k].value for k in *{"scale_x", "scale_y"} } 152 | 153 | uv_quad = Quad [ rel_quad\xy_to_uv(p) for p in *line.rel_line.quad ] 154 | if not options.trackpos 155 | -- Is this mode even useful in practice? Who knows! 156 | uv_quad += frame_quad\xy_to_uv(Point(tagvals.position.x, tagvals.position.y)) - rel_quad\xy_to_uv(Point(line.rel_line.tags.position.x, line.rel_line.tags.position.y)) 157 | -- This breaks if the lines have different alignments or if the relative line has its position shifted by something like \fax. If you have a better idea to find positions (and an actual use case for all this) I'd love to hear it. 158 | 159 | target_quad = Quad [ frame_quad\uv_to_xy(p) for p in *uv_quad ] 160 | 161 | -- Set up the tags 162 | data\removeTags relevantTags 163 | data\insertTags [ tagvals[k] for k in *usedTags ] 164 | 165 | tagsFromQuad(tagvals, target_quad, width, height, options.orgmode, layoutScale) 166 | 167 | -- -- Correct \bord and \shad for the \fscx\fscy change 168 | if options.trackbordshad 169 | for name in *{"outline", "shadow"} 170 | for coord in *{"x", "y"} 171 | tagvals["#{name}_#{coord}"].value *= tagvals["scale_#{coord}"].value / oldscale["scale_#{coord}"] 172 | 173 | if options.trackclip 174 | clip = (data\getTags {"clip_vect", "iclip_vect"})[1] 175 | if clip == nil 176 | rect = (data\removeTags {"clip_rect", "iclip_rect"})[1] 177 | if rect != nil 178 | clip = rect\getVect! 179 | clip\setInverse rect.__tag.inverse -- Because apparently assf sometimes decides to invert the clip? 180 | data\insertTags clip 181 | 182 | if clip != nil 183 | -- I'm sure there's a better way to do this but oh well... 184 | for cont in *clip.contours 185 | for cmd in *cont.commands 186 | for pt in *cmd\getPoints(true) 187 | -- We cannot exactly transform clips that contain cubic curves or splines, 188 | -- the best we can do is map all coordinates. For polygons this is accurate. 189 | -- If users need full accuracy, they can flatten their clip first. 190 | p = Point(pt.x, pt.y) 191 | uv = rel_quad\xy_to_uv p 192 | q = frame_quad\uv_to_xy uv 193 | pt.x = q\x! 194 | pt.y = q\y! 195 | 196 | -- Rejoice 197 | data\cleanTags 4 198 | data\commit! 199 | 200 | if options.includeextra 201 | line.extra["_aegi_perspective_ambient_plane"] = table.concat(["#{frame_quad[i]\x!};#{frame_quad[i]\y!}" for i=1,4], "|") 202 | 203 | lines\insertLines! 204 | lines\deleteLines to_delete 205 | 206 | 207 | parse_single_pin = (lines, marker) -> 208 | pin_pos = [ k for k, line in ipairs(lines) when line\match("^Effects[\t ]+CC Power Pin #1[\t ]+CC Power Pin%-#{marker}$") ] 209 | 210 | if #pin_pos != 1 211 | return nil 212 | 213 | i = pin_pos[1] + 2 214 | 215 | x = {} 216 | y = {} 217 | while lines[i]\match("^[\t ]+[0-9]") 218 | values = [ t for t in string.gmatch(lines[i], "%S+") ] 219 | table.insert(x, values[2]) 220 | table.insert(y, values[3]) 221 | i += 1 222 | 223 | return x, y 224 | 225 | -- function that contains everything that happens before the transforms 226 | parse_powerpin_data = (powerpin) -> 227 | -- Putting the user input into a table 228 | lines = [ line for line in string.gmatch(powerpin, "([^\n]*)\n?") ] 229 | 230 | return nil unless #([l for l in *lines when l\match"Effects[\t ]+CC Power Pin #1[\t ]+CC Power Pin%-0002"]) != 0 231 | 232 | -- FIXME sanity check more things here like the resolution and frame rate matching 233 | 234 | -- Filtering out everything other than the data, and putting them into their own tables. 235 | -- Power Pin data goes like this: TopLeft=0002, TopRight=0003, BottomRight=0005, BottomLeft=0004 236 | x1, y1 = parse_single_pin(lines, "0002") 237 | x2, y2 = parse_single_pin(lines, "0003") 238 | x3, y3 = parse_single_pin(lines, "0005") 239 | x4, y4 = parse_single_pin(lines, "0004") 240 | 241 | return nil if #x1 != #x2 242 | return nil if #x1 != #x3 243 | return nil if #x1 != #x4 244 | 245 | return [Quad {{x1[i], y1[i]}, {x2[i], y2[i]}, {x3[i], y3[i]}, {x4[i], y4[i]}} for i=1,#x1] 246 | 247 | 248 | main_dialog = (subs, sel, active) -> 249 | die("You need to have a video loaded for frame-by-frame tracking.") if aegisub.frame_from_ms(0) == nil 250 | 251 | active_line = subs[active] 252 | 253 | selection_start_frame = Point([ aegisub.frame_from_ms(subs[si].start_time) for si in *sel ])\min! 254 | selection_end_frame = Point([ aegisub.frame_from_ms(subs[si].end_time) for si in *sel ])\max! 255 | selection_frames = selection_end_frame - selection_start_frame 256 | 257 | clipboard_input = clipboard.get() or "" 258 | clipboard_data = parse_powerpin_data(clipboard_input) 259 | prefilled_data = if clipboard_data != nil and #clipboard_data == selection_frames then clipboard_input else "" 260 | 261 | lazy_heuristic = tonumber(active_line.text\match("\\fr[xy]([-.%deE]+)")) 262 | has_perspective = lazy_heuristic != nil and lazy_heuristic != 0 263 | 264 | video_frame = aegisub.project_properties().video_position 265 | rel_frame = if video_frame >= selection_start_frame and video_frame < selection_end_frame then 1 + video_frame - selection_start_frame else 1 266 | 267 | orgmodes = { 268 | "Keep original \\org", 269 | "Force center \\org", 270 | "Try to force \\fax0", 271 | } 272 | orgmodes_flip = {v,k for k,v in pairs(orgmodes)} 273 | 274 | button, results = aegisub.dialog.display({{ 275 | class: "label", 276 | label: "Paste your Power-Pin data here: ", 277 | x: 0, y: 0, width: 1, height: 1, 278 | }, { 279 | class: "textbox", 280 | name: "data", 281 | value: prefilled_data, 282 | x: 0, y: 1, width: 1, height: 7, 283 | }, { 284 | class: "label", 285 | label: "Relative to frame ", 286 | x: 1, y: 1, width: 1, height: 1, 287 | }, { 288 | class: "intedit", 289 | value: rel_frame, 290 | name: "relframe", 291 | min: 1, max: selection_frames, 292 | x: 2, y: 1, width: 1, height: 1, 293 | }, { 294 | class: "label", 295 | label: "\\org mode: ", 296 | x: 1, y: 2, width: 1, height: 1, 297 | }, { 298 | class: "dropdown", 299 | value: orgmodes[1], 300 | items: orgmodes, 301 | hint: "Controls how \\org will be handled when computing perspective tags, analogously to modes in Aegisub's perspective tool. This option should not change rendering except for rounding errors.", 302 | name: "orgmode", 303 | x: 2, y: 2, width: 1, height: 1, 304 | }, { 305 | class: "checkbox", 306 | name: "applyperspective", 307 | label: "Apply perspective", 308 | value: not has_perspective, 309 | x: 1, y: 3, width: 2, height: 1, 310 | }, { 311 | class: "checkbox", 312 | name: "includeextra", 313 | label: "Add quad to extradata", 314 | value: true, 315 | x: 1, y: 4, width: 2, height: 1, 316 | }, { 317 | class: "checkbox", 318 | name: "trackpos", 319 | label: "Track position", 320 | value: true, 321 | x: 0, y: 8, width: 1, height: 1, 322 | }, { 323 | class: "checkbox", 324 | name: "trackclip", 325 | label: "Track clips", 326 | value: true, 327 | x: 0, y: 9, width: 1, height: 1, 328 | }, { 329 | class: "checkbox", 330 | name: "trackbordshad", 331 | label: "Scale \\bord and \\shad", 332 | value: true, 333 | x: 0, y: 10, width: 1, height: 1, 334 | }}) 335 | 336 | return if not button 337 | 338 | die("No tracking data provided!") if results.data == "" 339 | 340 | quads = parse_powerpin_data results.data 341 | 342 | die("Invalid tracking data!") if quads == nil 343 | die("The length of the tracking data (#{#quads}) does not match the selected lines (#{selection_frames}).") if #quads != selection_frames 344 | 345 | results.selection_start_frame = selection_start_frame 346 | 347 | track(quads, results, subs, sel, active) 348 | 349 | dep\registerMacro main_dialog 350 | 351 | -------------------------------------------------------------------------------- /macros/arch.RWTools.lua: -------------------------------------------------------------------------------- 1 | script_name = "Rewriting Tools" 2 | script_author = "arch1t3cht" 3 | script_version = "1.3.2" 4 | script_namespace = "arch.RWTools" 5 | script_description = "Shortcuts for managing multiple rewrites of a line in one .ass event line." 6 | 7 | haveDepCtrl, DependencyControl = pcall(require, "l0.DependencyControl") 8 | 9 | default_config = { 10 | default_signature = "OG", 11 | personal_signature = "", 12 | auto_fixes = true, 13 | forbid_nested = true, 14 | } 15 | 16 | if haveDepCtrl then 17 | depctrl = DependencyControl({ 18 | feed = "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 19 | }) 20 | config = depctrl:getConfigHandler(default_config, "config") 21 | else 22 | id = function() return nil end 23 | config = {c = default_config, load = id, write = id} 24 | end 25 | 26 | -- This script automates the process of commenting and uncommenting subitle lines during rewrites, 27 | -- using conventions used in some of the groups I'm working in. 28 | -- 29 | -- Line format (formal-ish specification - scroll down for the functions and explicit examples): 30 | -- Every line of a subtitle file is a sequence of sections of one of the following forms: 31 | -- - Global styling tags: A brace block whose content starts with a backslash and ends with a pipe ('|'). 32 | -- These are styling overrides which should globally apply to any rewrite (e.g. position, alignment, font size, etc) 33 | -- as opposed to local styling tags like those italicizing certain words. 34 | -- 35 | -- Each (contiguous set) of global styling tags starts a new text section, each of which will have its self-contained set of rewrites. 36 | -- To start such a section without changing styles, simply use an empty tag of this format like {\|}. 37 | -- 38 | -- - Text sections: The text before, after, or in between two global styling tags. 39 | -- Each of these sections reprents one bit of text, which is open for rewrites. 40 | -- Such a section should be a sequence of blocks of one of the following forms: 41 | -- - Inactive line: A brace block not started by a backslash or asterisk (Asterisks are allowed for compatibility with Dialog Swapper). 42 | -- This can be any brace block matching this format (so also miscellaneous comments that aren't actual lines), 43 | -- But in order for the script to interact with them, they should have the format 44 | -- { - } , 45 | -- where the signature could potentially also contain other comments. 46 | -- In the , should be escaped by replacing their braces with square brackets, 47 | -- and the backslashes by harmless forward slashes. 48 | -- 49 | -- - Active line: A block of the form 50 | -- [{}] , 51 | -- where the is allowed to contain (local) styling tags, and the signature 52 | -- must not begin with a backslash or asterisk and is only allowed to be omitted when this block is the last 53 | -- in its section (since otherwise the following inactive line would be incorrectly recognized as the signature) 54 | -- 55 | -- Only one of the blocks in a section should be an active line at any time. 56 | -- 57 | -- In most cases, there will be only one non-empty text section. 58 | -- 59 | -- The script's main function "Switch Active Lines" will, for each section: 60 | -- - Deactivate any active lines, signing them with the default signature (defaults to "OG") if no signature is present. 61 | -- - Activate any inactive lines where the " - " separating the line from the signature has been marked by replacing it with " !- ". 62 | -- Thus, there should ideally be just one line per section marked this way. 63 | -- - If there is a block that has been deactivated, but no block has been activated (suggesting that the user wants to write a new suggested line), 64 | -- it will add a new signature at the end of the section, providing that one is specified in the configuration. 65 | -- 66 | -- The function "Prepare Rewrite" will do all of the above, but also 67 | -- - Copy the line that was just deactivated to the end of the section, before the added signature 68 | -- That way, the user can quickly propose a small change in the line. 69 | -- - Always add a signature, as long as one is configured. 70 | -- 71 | -- The functions "Shift Line Break Forward" and "Shift Line Break Backward" will shift the line break in the currently active line(s) 72 | -- forward or backward respectively, by one word (i.e. one contiguous sequence of non-space characters). 73 | -- 74 | -- The function "Clean Up Styling Tag Escapes" will simply remove all the pipe ('|') characters from global styling tags. 75 | -- 76 | -- Examples: 77 | -- - Deactivating the line 78 | -- {\i1}Hello{\i0}, world!{author} 79 | -- Will turn it into 80 | -- {[/i1]Hello[/i0], world! - author} 81 | -- If a personal signature like "me" is set in the configuration, it will automatically be added: 82 | -- {[/i1]Hello[/i0], world! - author}{me} 83 | -- 84 | -- - Deactivating the line 85 | -- {\an8|}Hello, {\i1}world{\i0}! 86 | -- Will turn it into 87 | -- {\an8|}{Hello,[/i1]world[/i0]! - OG} 88 | -- where the default signature "OG" can be set to a different on in the configuration. 89 | -- 90 | -- - Applying "Switch Lines" to the line 91 | -- {Hello, [/i1]world[/i0]! !- OG}{foo - bar} . 92 | -- Will turn it into 93 | -- Hello, {\i1}world{\i0}!{OG}{foo - bar} . 94 | -- Applying "Switch Lines" again will turn this line into 95 | -- {Hello, [/i1]world[/i0]! - OG}{foo - bar} , 96 | -- which is just the beginning line without the marker. 97 | -- On the other hand, applying "Prepare Rewrite" to this second line will turn it into 98 | -- {Hello, [/i1]world[/i0]! - OG}{foo - bar}Hello, {\i1}world{\i0}!{me} , 99 | -- where "me" is the personal signature set in the configuration. 100 | -- 101 | -- - A more complex example containing multiple sections: Consider a line where two people speak simultaneously, 102 | -- which is represented using en dashes (represented as hyphens here): 103 | -- - foo!\N- bar! 104 | -- To make a rewrite for only one of these lines, add a separator after \N: 105 | -- - foo!\N{\|}- bar! 106 | -- Now these can be both be rewritten with "Prepare Rewrite": 107 | -- {- foo!\N - OG}- foo!\N{me}{\|}{- bar! - OG}- bar!{me} 108 | -- Say we want to rewrite the first line to "- foo2!\N", and don't rewrite the second line: 109 | -- {- foo!\N - OG}- foo2!\N{me}{\|}{- bar! - OG} 110 | -- Apply "Switch Lines" again: 111 | -- {- foo!\N - OG}{- foo2!\N - me}{me}{\|}{- bar! - OG} 112 | -- This will cause a duplicate signature, but preventing this would require a lot more macro options. Remove this duplicate, and mark both lines: 113 | -- {- foo!\N - OG}{- foo2!\N !- me}{\|}{- bar! !- OG} 114 | -- Finally, apply "Switch Lines": 115 | -- {- foo!\N - OG}- foo2!{me}\N{\|}- bar!{OG} 116 | -- This specific application is very tedious for small rewrites, but can still greatly speed up the process for longer sections with formatting. 117 | -- 118 | -- It is strongly recommended to bind the macros for rewriting and shifting line breaks to keybinds. 119 | -- I use Ctrl+K and Ctrl+Shift+K respectively for "Switch Active Lines" and "Prepare Rewrite", 120 | -- as well as Ctrl+, and Ctrl+. respectively for "Shift Line Break Backward" and "Shift Line Break Forward". 121 | 122 | 123 | function unreachable() 124 | if not val then 125 | aegisub.log("Incorrect line format! Aborting.") 126 | aegisub.cancel() 127 | end 128 | end 129 | 130 | 131 | -- These two functions are split up because signed deactivated lines like 132 | -- {Upper line\N - author} would be broken by simply stripping spaces 133 | -- surrounding \N tags everywhere. 134 | -- An unintended but welcome side effect is that only those lines which the 135 | -- user touches will have text fixes applied - this ensures a basic level 136 | -- of reversibility. 137 | function fix_text(line) 138 | -- Applies some basic formatting fixes: 139 | -- Removes spaces surrounding \N tags 140 | while true do 141 | local newline = line 142 | :gsub(" *\\N *([^!])", "\\N%1") 143 | :gsub(" *\\n *", "\\n") 144 | 145 | if newline == line then 146 | return line 147 | end 148 | line = newline 149 | end 150 | end 151 | 152 | function fix_line_format(line) 153 | -- Applies some basic formatting fixes: 154 | -- Removes spaces surrounding or padding {} blocks 155 | while true do 156 | local newline = line 157 | :gsub("{ *", "{") 158 | :gsub("({[^}]-) *}", "%1}") -- make sure '}' is closing a tag here (still breaks if there's nested tags, but this is the best lua patterns can do.) 159 | 160 | if newline == line then 161 | return line 162 | end 163 | line = newline 164 | end 165 | end 166 | 167 | function fix_text_checked(line) 168 | if config.c.auto_fixes then 169 | return fix_text(line) 170 | else 171 | return line 172 | end 173 | end 174 | 175 | function fix_line_format_checked(line) 176 | if config.c.auto_fixes then 177 | return fix_line_format(line) 178 | else 179 | return line 180 | end 181 | end 182 | 183 | function stripstart(intext, out) 184 | local ws = intext:match("^ *") 185 | intext = intext:sub(#ws + 1) 186 | if not config.c.auto_fixes then 187 | out = out .. ws 188 | end 189 | return intext, out 190 | end 191 | 192 | function appendstripend(text, out) 193 | if config.c.auto_fixes then 194 | local ws = text:match(" *$") 195 | text = text:sub(1, #text - #ws) 196 | end 197 | return out .. text 198 | end 199 | 200 | function get_signature(sign) 201 | if sign ~= "" then 202 | return "{" .. sign .. "}" 203 | end 204 | return "" 205 | end 206 | 207 | -- Arguments 208 | -- shift: True if shifting line breaks, false otherwise 209 | -- shift_dir: If shifting line breaks, true if shifting forward, else false 210 | -- rewrite: If not shifting line breaks, true if the line being deactivated should be copied for a rewrite. 211 | function switch_lines_proper(subs, sel, rewrite, shift, shift_dir) 212 | local insertion_point_marker = "{/|\\}" 213 | local new_insertion_point = nil 214 | for _, i in ipairs(sel) do 215 | local line = subs[i] 216 | 217 | if config.c.forbid_nested then 218 | if line.text:match("{[^}]+{") then 219 | aegisub.log("Nested braces detected! Please fix them before running the script. Aborting...") 220 | return 221 | end 222 | end 223 | 224 | -- local intext = fix_line_format_checked(line.text) 225 | local intext = line.text 226 | local out = "" 227 | 228 | local deactivated = false 229 | local reactivated = false 230 | local newline = "" 231 | 232 | while true do -- parse components of the line 233 | intext, out = stripstart(intext, out) 234 | local escaped_braceblock = intext:match("^{\\[^}]-|}") 235 | local deactive_line = intext:match("^{[^\\*][^}]-}") 236 | -- and some edge cases: 237 | local empty_block = intext:match("^{}") 238 | local unterminated_brace = intext:match("^{[^}]*$") 239 | 240 | if escaped_braceblock ~= nil or intext == "" then 241 | if rewrite then 242 | newline, out = stripstart(newline, out) 243 | newline = fix_text_checked(newline) 244 | out = appendstripend(newline, out) 245 | end 246 | out = out .. insertion_point_marker 247 | if (deactivated and not reactivated) or (rewrite and newline ~= "") then 248 | out = out .. get_signature(config.c.personal_signature) 249 | end 250 | if intext == "" then 251 | break 252 | end 253 | out = out .. escaped_braceblock 254 | intext = intext:sub(#escaped_braceblock + 1) 255 | 256 | deactivated = false 257 | reactivated = false 258 | newline = "" 259 | elseif deactive_line ~= nil then 260 | if shift or deactive_line:match(" !%- ") == nil then 261 | out = out .. deactive_line 262 | else 263 | local reactivate_line = deactive_line 264 | -- evil capture hacks to replace only those forward slashes 265 | -- by backslashes, that are contained in square brackets. 266 | while true do 267 | local r = reactivate_line:gsub("(%[[^%]]-)/([^%]]-%])", "%1%\\%2") 268 | if r == reactivate_line then 269 | break 270 | end 271 | reactivate_line = r 272 | end 273 | if config.c.auto_fixes then 274 | reactivate_line = reactivate_line:gsub(" * !%- ", " !- ") 275 | end 276 | 277 | out = out .. fix_text_checked(reactivate_line:gsub("^{", ""):gsub("%[", "{"):gsub("%]", "}"):gsub(" !%- ", "{")) 278 | reactivated = true 279 | end 280 | 281 | intext = intext:sub(#deactive_line + 1) 282 | elseif empty_block ~= nil then 283 | -- just ignore it 284 | out = out .. empty_block 285 | intext = intext:sub(#empty_block + 1) 286 | elseif unterminated_brace ~= nil then 287 | -- terminate it and (effectively) abort 288 | out = out .. unterminated_brace 289 | intext = intext:sub(#unterminated_brace + 1) 290 | else -- beginning of an active line 291 | local linetext = "" 292 | local linesignature = config.c.default_signature 293 | -- this could be done with a bigger regex and some gsubs, 294 | -- but in edge cases with broken nested braces this might hold up better 295 | while intext ~= "" do 296 | local escaped_braceblock = intext:match("^{[\\*][^}]-|}") 297 | local styling_braceblock = intext:match("^{[\\*][^}]-}") 298 | local cleartext = intext:match("^[^{]+") 299 | local signature = intext:match("^{[^}]-}") 300 | 301 | if escaped_braceblock ~= nil then 302 | break 303 | elseif styling_braceblock ~= nil then 304 | newline = newline .. styling_braceblock 305 | if not shift then 306 | linetext = linetext .. styling_braceblock:gsub("{", "["):gsub("}", "]"):gsub("\\", "/") 307 | else 308 | linetext = linetext .. styling_braceblock 309 | end 310 | intext = intext:sub(#styling_braceblock + 1) 311 | elseif cleartext ~= nil then 312 | newline = newline .. cleartext 313 | if not shift then 314 | linetext = linetext .. cleartext 315 | else 316 | ctext = cleartext 317 | if shift_dir then 318 | if not ctext:match("\\N") then 319 | ctext = "\\N" .. ctext 320 | end 321 | ctext = ctext 322 | -- Shift a newline forward that's in somewhere in the middle of the line. Won't shift a new line to the very left. 323 | :gsub("([^ ]+) *\\N *([^ ]+) *", "%1 %2\\N") 324 | -- Shift a newline forward that's to the very left of the line. 325 | :gsub("^ *\\N *([^ ]+) *", "%1\\N") 326 | -- Remove a newline to the very right of the line. 327 | :gsub(" *\\N *$", "") 328 | else 329 | if not ctext:match("\\N") then 330 | ctext = ctext .. "\\N" 331 | end 332 | ctext = ctext 333 | :gsub(" *([^ ]+) *\\N *([^ ]+)", "\\N%1 %2") 334 | :gsub(" *([^ ]+) *\\N *$", "\\N%1") 335 | :gsub("^ *\\N *", "") 336 | end 337 | linetext = linetext .. ctext 338 | end 339 | intext = intext:sub(#cleartext + 1) 340 | elseif signature ~= nil then 341 | if #signature >= 2 then 342 | assert(signature[2] ~= "|" and signature[2] ~= "\\") 343 | end 344 | 345 | if not shift then 346 | linesignature = signature:gsub("[{}]", "") 347 | else 348 | linetext = linetext .. signature 349 | end 350 | 351 | intext = intext:sub(#signature + 1) 352 | break 353 | else 354 | assert(false) 355 | end 356 | end 357 | 358 | if not shift then 359 | out = out .. "{" .. fix_text_checked(linetext) 360 | if linesignature ~= "" then 361 | out = out .. " - " .. linesignature 362 | end 363 | out = out .. "}" 364 | else 365 | out = out .. linetext 366 | end 367 | 368 | deactivated = not shift 369 | end 370 | end 371 | 372 | out = fix_line_format_checked(out) 373 | new_insertion_point = out:find(insertion_point_marker) 374 | out = out:gsub(insertion_point_marker, "") 375 | 376 | line.text = out 377 | 378 | subs[i] = line 379 | end 380 | 381 | if new_insertion_point ~= nil and aegisub.gui ~= nil then 382 | aegisub.gui.set_cursor(new_insertion_point) 383 | end 384 | end 385 | 386 | function clean_lines(subs, sel) 387 | load_config() 388 | for _, i in ipairs(sel) do 389 | local line = subs[i] 390 | 391 | while true do 392 | local newtext = line.text:gsub("({\\[^}]-)|}", "%1}") 393 | 394 | if newtext == line.text then 395 | break 396 | end 397 | 398 | line.text = newtext 399 | end 400 | 401 | subs[i] = line 402 | end 403 | end 404 | 405 | function switch_lines(subs, sel) 406 | switch_lines_proper(subs, sel, false) 407 | end 408 | 409 | function rewrite_line(subs, sel) 410 | switch_lines_proper(subs, sel, true) 411 | end 412 | 413 | function shift_forward(subs, sel) 414 | switch_lines_proper(subs, sel, false, true, true) 415 | end 416 | 417 | function shift_backward(subs, sel) 418 | switch_lines_proper(subs, sel, false, true, false) 419 | end 420 | 421 | function configure() 422 | config:load() 423 | local diag = { 424 | {class = 'label', label = 'Default signature', x = 0, y = 0, width = 1, height = 1}, 425 | { 426 | class = 'edit', 427 | name = 'default_signature', 428 | hint = "Signature to sign unsigned active lines with. Leave blank to deactivate.", 429 | value = config.c.default_signature, 430 | x = 1, y = 0, width = 1, height = 1, 431 | }, 432 | {class = 'label', label = 'Your signature', x = 0, y = 1, width = 1, height = 1}, 433 | { 434 | class = 'edit', 435 | name = 'personal_signature', 436 | hint = "Signature to automatically insert when deactivating a line. Leave blank to deactivate.", 437 | value = config.c.personal_signature, 438 | x = 1, y = 1, width = 1, height = 1, 439 | }, 440 | {class = 'label', label = 'Apply auto fixes', x = 0, y = 2, width = 1, height = 1}, 441 | { 442 | class = 'checkbox', 443 | name = 'auto_fixes', 444 | hint = "Whether to automatically remove unnecessary spaces. Deactivate if you know what you're doing (e.g. for special typesetting).", 445 | value = config.c.auto_fixes, 446 | x = 1, y = 2, width = 1, height = 1, 447 | }, 448 | {class = 'label', label = 'Check for nested braces', x = 0, y = 3, width = 1, height = 1}, 449 | { 450 | class = 'checkbox', 451 | name = 'forbid_nested', 452 | hint = "Whether to abort if nested braces are found. Deactivate if you know what you're doing.", 453 | value = config.c.forbid_nested, 454 | x = 1, y = 3, width = 1, height = 1, 455 | }, 456 | } 457 | local buttons = {'OK', 'Cancel'} 458 | local button_ids = {ok = 'OK', cancel = 'Cancel'} 459 | local button, results = aegisub.dialog.display(diag, buttons, button_ids) 460 | if button == false then aegisub.cancel() end 461 | 462 | for i,v in ipairs({"personal_signature", "default_signature", "auto_fixes", "forbid_nested"}) do 463 | if results[v] ~= config.c[v] then 464 | config.c[v] = results[v] 465 | end 466 | end 467 | 468 | config:write() 469 | 470 | return results 471 | end 472 | 473 | local mymacros = {} 474 | 475 | function wrap_register_macro(name, ...) 476 | if haveDepCtrl then 477 | table.insert(mymacros, {name, ...}) 478 | else 479 | aegisub.register_macro(script_name .. "/" .. name, ...) 480 | end 481 | end 482 | 483 | wrap_register_macro("Switch Active Lines", "Deactivates the active line and activates any inactive lines marked with !- .", switch_lines) 484 | wrap_register_macro("Prepare Rewrite", "Deactivates the active line and copies it to a new line for rewriting.", rewrite_line) 485 | wrap_register_macro("Shift Line Break Forward", "Shifts the line break in the currently active line forward by one word.", shift_forward) 486 | wrap_register_macro("Shift Line Break Backward", "Shifts the line break in the currently active line backward by one word.", shift_backward) 487 | wrap_register_macro("Clean Up Styling Tag Escapes", "Removes all pipe ('|') characters from the end of styling blocks.", clean_lines) 488 | wrap_register_macro("Configure", "Configure Rewriting Tools", configure) 489 | 490 | 491 | if haveDepCtrl then 492 | depctrl:registerMacros(mymacros) 493 | end 494 | -------------------------------------------------------------------------------- /macros/arch.Resample.moon: -------------------------------------------------------------------------------- 1 | export script_name = "Resample Perspective" 2 | export script_description = "Apply after resampling a script in Aegisub to fix any lines with 3D rotations." 3 | export script_author = "arch1t3cht" 4 | export script_namespace = "arch.Resample" 5 | export script_version = "2.1.0" 6 | 7 | DependencyControl = require "l0.DependencyControl" 8 | dep = DependencyControl{ 9 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 10 | { 11 | {"a-mo.LineCollection", version: "1.3.0", url: "https://github.com/TypesettingTools/Aegisub-Motion", 12 | feed: "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json"}, 13 | {"l0.ASSFoundation", version: "0.5.0", url: "https://github.com/TypesettingTools/ASSFoundation", 14 | feed: "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 15 | {"arch.Math", version: "0.1.8", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 16 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 17 | {"arch.Perspective", version: "1.0.0", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 18 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 19 | } 20 | } 21 | LineCollection, ASS, AMath, APersp = dep\requireModules! 22 | {:Matrix} = AMath 23 | {:relevantTags, :usedTags, :transformPoints, :tagsFromQuad, :prepareForPerspective} = APersp 24 | 25 | logger = dep\getLogger! 26 | 27 | resample = (ratiox, ratioy, orgmode, subs, sel) -> 28 | anamorphic = math.max(ratiox, ratioy) / math.min(ratiox, ratioy) > 1.01 29 | 30 | lines = LineCollection subs, sel, () -> true 31 | lines\runCallback (lines, line) -> 32 | data = ASS\parse line 33 | 34 | -- No perspective tags, we don't need to do anything 35 | return if not anamorphic and #data\getTags({"angle_x", "angle_y"}) == 0 36 | 37 | tagvals, width, height, warnings = prepareForPerspective(ASS, data) 38 | 39 | for warn in *warnings 40 | switch warn[1] 41 | when "multiple_tags" 42 | aegisub.log("Warning: Line #{line.humanizedNumber} has more than one #{warn[2]} tag! This might break resampling.\n") if warn[2] == "\\frx" or warn[2] == "\\fry" 43 | when "transform" 44 | aegisub.log("Warning: Line #{line.humanizedNumber} contains a #{warn[2]} tag in a transform tag! This might break resampling.\n") if warn[2] == "\\frx" or warn[2] == "\\fry" 45 | 46 | return if not anamorphic and tagvals.angle_x.value == 0 and tagvals.angle_y.value == 0 47 | 48 | for warn in *warnings 49 | switch warn[1] 50 | when "multiple_tags" 51 | aegisub.log("Warning: Line #{line.humanizedNumber} has more than one #{warn[2]} tag! This might break resampling.\n") 52 | when "transform" 53 | aegisub.log("Warning: Line #{line.humanizedNumber} contains a #{warn[2]} tag in a transform tag! This might break resampling.\n") 54 | when "zero_size" 55 | aegisub.log("Warning: Line #{line.humanizedNumber} has zero width or height!\n") 56 | when "move" 57 | aegisub.log("Line #{line.humanizedNumber} has \\move! Skipping.\n") 58 | return 59 | when "text_and_drawings" 60 | aegisub.log("Line #{line.humanizedNumber} has both text and drawings! Skipping.\n") 61 | return 62 | else 63 | aegisub.log("Unknown warning on line #{line.humanizedNumber}: #{warn[1]}\n") 64 | 65 | -- Set up the tags 66 | data\removeTags relevantTags 67 | data\insertTags [ tagvals[k] for k in *usedTags ] 68 | 69 | -- Revert Aegisub's resampling. 70 | for tag in *{"position", "origin"} 71 | tagvals[tag].x *= ratiox 72 | tagvals[tag].y *= ratioy 73 | 74 | tagvals.scale_x.value *= (ratiox / ratioy) -- Aspect ratio resampling 75 | 76 | -- Store the previous \fscx\fscy 77 | oldscale = { k,tagvals[k].value for k in *{"scale_x", "scale_y"} } 78 | 79 | -- Get the original rendered quad 80 | -- Note that we use ratioy in both dimensions here, since font sizes in .ass rendering 81 | -- only scale with the height. 82 | quad = transformPoints(tagvals, ratioy * width, ratioy * height) 83 | 84 | -- Transform it back to the new coordinates 85 | tagvals.origin.x /= ratiox 86 | tagvals.origin.y /= ratioy 87 | quad *= Matrix.diag(1 / ratiox, 1 / ratioy) 88 | tagsFromQuad(tagvals, quad, width, height, orgmode) 89 | 90 | -- Correct \bord and \shad for the \fscx\fscy change 91 | for name in *{"outline", "shadow"} 92 | for coord in *{"x", "y"} 93 | tagvals["#{name}_#{coord}"].value *= tagvals["scale_#{coord}"].value / oldscale["scale_#{coord}"] 94 | 95 | -- Rejoice 96 | data\cleanTags 4 97 | data\commit! 98 | lines\replaceLines! 99 | 100 | 101 | resample_ui = (subs, sel) -> 102 | video_width, video_height = aegisub.video_size! 103 | 104 | orgmodes = { 105 | "Keep original \\org", 106 | "Force center \\org", 107 | "Try to force \\fax0", 108 | } 109 | orgmodes_flip = {v,k for k,v in pairs(orgmodes)} 110 | 111 | button, results = aegisub.dialog.display({{ 112 | class: "label", 113 | label: "Source Resolution: ", 114 | x: 0, y: 0, width: 1, height: 1, 115 | }, { 116 | class: "intedit", 117 | name: "srcresx", 118 | value: 1280, 119 | x: 1, y: 0, width: 1, height: 1, 120 | }, { 121 | class: "label", 122 | label: "x", 123 | x: 2, y: 0, width: 1, height: 1, 124 | }, { 125 | class: "intedit", 126 | name: "srcresy", 127 | value: 720, 128 | x: 3, y: 0, width: 1, height: 1, 129 | }, { 130 | class: "label", 131 | label: "Target Resolution: ", 132 | x: 0, y: 1, width: 1, height: 1, 133 | }, { 134 | class: "intedit", 135 | name: "targetresx", 136 | value: video_width or 1920, 137 | x: 1, y: 1, width: 1, height: 1, 138 | }, { 139 | class: "label", 140 | label: "x", 141 | x: 2, y: 1, width: 1, height: 1, 142 | }, { 143 | class: "intedit", 144 | name: "targetresy", 145 | value: video_height or 1080, 146 | x: 3, y: 1, width: 1, height: 1, 147 | }, { 148 | class: "label", 149 | label: "\\org mode: ", 150 | x: 0, y: 2, width: 1, height: 1, 151 | }, { 152 | class: "dropdown", 153 | value: orgmodes[1], 154 | items: orgmodes, 155 | hint: "Controls how \\org will be handled when computing perspective tags, analogously to modes in Aegisub's perspective tool. This option should not change rendering except for rounding errors.", 156 | name: "orgmode", 157 | x: 1, y: 2, width: 2, height: 1, 158 | }}) 159 | 160 | resample(results.srcresx / results.targetresx, results.srcresy / results.targetresy, orgmodes_flip[results.orgmode], subs, sel) if button 161 | 162 | dep\registerMacro resample_ui 163 | -------------------------------------------------------------------------------- /macros/arch.SplitSections.moon: -------------------------------------------------------------------------------- 1 | export script_name = "Split Tag Sections" 2 | export script_description = "Split subtitle lines at tags, creating a separate event for each section" 3 | export script_author = "arch1t3cht" 4 | export script_namespace = "arch.SplitSections" 5 | export script_version = "0.1.1" 6 | 7 | DependencyControl = require "l0.DependencyControl" 8 | dep = DependencyControl{ 9 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 10 | { 11 | {"a-mo.Line", version: "1.5.3", url: "https://github.com/TypesettingTools/Aegisub-Motion", 12 | feed: "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json"}, 13 | {"a-mo.LineCollection", version: "1.3.0", url: "https://github.com/TypesettingTools/Aegisub-Motion", 14 | feed: "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json"}, 15 | {"l0.ASSFoundation", version: "0.5.0", url: "https://github.com/TypesettingTools/ASSFoundation", 16 | feed: "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 17 | } 18 | } 19 | Line, LineCollection, ASS = dep\requireModules! 20 | 21 | an_xshift = { 0, 0.5, 1, 0, 0.5, 1, 0, 0.5, 1 } 22 | an_yshift = { 1, 1, 1, 0.5, 0.5, 0.5, 0, 0, 0 } 23 | 24 | logger = dep\getLogger! 25 | 26 | split = (subs, sel) -> 27 | lines = LineCollection subs, sel, () -> true 28 | 29 | toDelete = {} 30 | 31 | lines\runCallback (lines, line) -> 32 | data = ASS\parse line 33 | 34 | efftags = data\getEffectiveTags(-1, true, true, true).tags 35 | pos = data\getPosition() 36 | if pos.class == ASS.Tag.Move 37 | aegisub.log("Warning: Line #{line.humanizedNumber} has \\move. Skipping.") 38 | return 39 | 40 | table.insert toDelete, line 41 | 42 | an = efftags.align.value 43 | hasorg = #data\getTags({"origin"}) != 0 44 | 45 | x = 0 46 | y = 0 47 | 48 | lineheight = 0 49 | linedescent = 0 50 | 51 | splitLines = {} 52 | 53 | data\callback (section, _, i, j) -> 54 | return unless section.class == ASS.Section.Text or section.class == ASS.Section.Drawing 55 | 56 | -- TODO handle newlines 57 | 58 | splitLine = Line line, lines, {ASS: {}} 59 | splitSections = data\get ASS.Section.Tag, 1, i 60 | splitSections[#splitSections+1] = section 61 | splitLine.ASS = ASS.LineContents splitLine, splitSections 62 | 63 | lines\addLine splitLine 64 | table.insert splitLines, splitLine 65 | 66 | if section.class == ASS.Section.Text 67 | splitLine.width, splitLine.height, splitLine.descent = section\getTextExtents! 68 | else 69 | ext = section\getExtremePoints! 70 | splitLine.width, splitLine.height, splitLine.descent = ext.w, ext.h, 0 71 | 72 | splitLine.x = x 73 | 74 | x += splitLine.width 75 | lineheight = math.max(lineheight, splitLine.height) 76 | linedescent = math.max(linedescent, splitLine.descent) 77 | 78 | 79 | for splitLine in *splitLines 80 | xshift = splitLine.x + an_xshift[an] * (splitLine.width - x) 81 | yshift = (1 - an_yshift[an]) * (lineheight - splitLine.height) - (linedescent - splitLine.descent) 82 | 83 | -- We ensured above that this is not a move tag 84 | splitpos = splitLine.ASS\getPosition() 85 | splitpos.x += xshift 86 | splitpos.y += yshift 87 | 88 | if not hasorg 89 | splitLine.ASS\insertTags { 90 | ASS\createTag "origin", pos.x, pos.y 91 | } 92 | 93 | splitLine.ASS\cleanTags 4 94 | splitLine.ASS\commit! 95 | 96 | lines\insertLines! 97 | lines\deleteLines toDelete 98 | 99 | dep\registerMacro split 100 | -------------------------------------------------------------------------------- /macros/arch.TimingBinds.lua: -------------------------------------------------------------------------------- 1 | script_name = "Timing Shortcuts" 2 | script_author = "arch1t3cht" 3 | script_version = "1.1.0" 4 | script_namespace = "arch.TimingBinds" 5 | script_description = "Some shorthands for timing" 6 | 7 | 8 | function do_snap(subs, sel, active_line, to_end) 9 | frame = aegisub.project_properties().video_position 10 | if to_end then 11 | time = aegisub.ms_from_frame(frame + 1) 12 | else 13 | time = aegisub.ms_from_frame(frame) 14 | end 15 | 16 | otherindex = active_line - 1 17 | if to_end then otherindex = active_line + 1 end 18 | 19 | line = subs[active_line] 20 | otherline = subs[otherindex] 21 | if otherline == nil or otherline.class ~= "dialogue" then 22 | otherline = nil 23 | end 24 | 25 | if otherline ~= nil and (not to_end and otherline.end_time == line.start_time or to_end and otherline.start_time == line.end_time) then 26 | if to_end then 27 | otherline.start_time = time 28 | else 29 | otherline.end_time = time 30 | end 31 | subs[otherindex] = otherline 32 | end 33 | 34 | if to_end then 35 | line.end_time = time 36 | else 37 | line.start_time = time 38 | end 39 | 40 | subs[active_line] = line 41 | 42 | return true 43 | end 44 | 45 | function snap_beginning_to_video(subs, sel, active_line) 46 | return do_snap(subs, sel, active_line, false) 47 | end 48 | 49 | function snap_end_to_video(subs, sel, active_line) 50 | return do_snap(subs, sel, active_line, true) 51 | end 52 | 53 | function has_video(subs, sel) 54 | if aegisub.frame_from_ms(0) == nil then 55 | return false, "No video opened!" 56 | end 57 | return true 58 | end 59 | 60 | function shift_frames_to_video(end_time, after) return function(subs, sel, active_line) 61 | active_line_frame = aegisub.frame_from_ms(end_time and subs[active_line].end_time or subs[active_line].start_time) 62 | video_frame = aegisub.project_properties().video_position 63 | if video_frame == nil then 64 | return false, "No video opened!" 65 | end 66 | 67 | frame_diff = video_frame - active_line_frame 68 | 69 | if after then 70 | for i, line in ipairs(subs) do 71 | if line.class == "dialogue" and line.start_time >= subs[active_line].start_time then 72 | table.insert(sel, i) 73 | end 74 | end 75 | end 76 | 77 | table.sort(sel) 78 | processed = {} 79 | 80 | for i, s in ipairs(sel) do 81 | if not processed[s] then 82 | line = subs[s] 83 | 84 | line.start_time = aegisub.ms_from_frame(aegisub.frame_from_ms(line.start_time) + frame_diff) 85 | line.end_time = aegisub.ms_from_frame(aegisub.frame_from_ms(line.end_time) + frame_diff) 86 | 87 | subs[s] = line 88 | processed[s] = true 89 | end 90 | end 91 | end end 92 | 93 | function join_previous(subs, sel, active_line) 94 | sstart, send = aegisub.get_audio_selection() 95 | line = subs[active_line] 96 | line.start_time = sstart 97 | line.end_time = send 98 | subs[active_line] = line 99 | 100 | prevline = subs[active_line - 1] 101 | if prevline == nil or prevline.class ~= "dialogue" then 102 | return 103 | end 104 | prevline.end_time = sstart 105 | subs[active_line - 1] = prevline 106 | end 107 | 108 | aegisub.register_macro("Timing Binds/Snap Beginning to Frame","Snap the current line's beginning to the current frame, but also snap the previous line's end, if the lines were joined.",snap_beginning_to_video, has_video) 109 | aegisub.register_macro("Timing Binds/Snap End to Frame","Snap the current line's end to the current frame, but also snap the following line's end, if the lines were joined.",snap_end_to_video, has_video) 110 | aegisub.register_macro("Timing Binds/Join Previous Line","Joins the previous line's end to the current line's beginning.", join_previous) 111 | aegisub.register_macro("Timing Binds/Shift by Frames to Video Position","Shifts the selection such that the active line starts at the video position, but shifts by frames instead of by milliseconds.", shift_frames_to_video(false, false), has_video) 112 | aegisub.register_macro("Timing Binds/Shift by Frames to Video Position (End)","Shifts the selection such that the active line end at the video position, but shifts by frames instead of by milliseconds.", shift_frames_to_video(true, false), has_video) 113 | aegisub.register_macro("Timing Binds/Shift all following lines by Frames to Video Position","Shifts all following lines such that the active line starts at the video position, but shifts by frames instead of by milliseconds.", shift_frames_to_video(false, true), has_video) 114 | aegisub.register_macro("Timing Binds/Shift all following lines by Frames to Video Position (End)","Shifts all following lines such that the active line ends at the video position, but shifts by frames instead of by milliseconds.", shift_frames_to_video(true, true), has_video) 115 | -------------------------------------------------------------------------------- /modules/arch/Math.moon: -------------------------------------------------------------------------------- 1 | haveDepCtrl, DependencyControl, depctrl = pcall require, 'l0.DependencyControl' 2 | 3 | if haveDepCtrl 4 | depctrl = DependencyControl { 5 | name: "ArchMath", 6 | version: "0.1.10", 7 | description: [[General-purpose linear algebra functions, approximately matching the patterns of Matlab or numpy]], 8 | author: "arch1t3cht", 9 | url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 10 | moduleName: 'arch.Math', 11 | {} 12 | } 13 | 14 | -- This is a collection of functions I needed for Perspective.moon, and some infrastructure around them: 15 | -- - Vectors in n-dimensional space 16 | -- - Matrices 17 | -- - Some linear algebra, in particular LU decomposition 18 | -- In no way do I claim that this is feature-complete (in fact this is already overengineered to oblivion), 19 | -- but PR's are very welcome. 20 | 21 | 22 | -- By making all my classes inherit from this, I can make metatables entries to be inherited by child classes. 23 | -- See https://github.com/leafo/moonscript/issues/51#issuecomment-36732147 . 24 | class ClassFix 25 | __inherited: (C) => 26 | for i,v in next,@__base 27 | C.__base[i] or= v 28 | 29 | 30 | id = (...) -> ... 31 | 32 | local Matrix 33 | local Point 34 | 35 | 36 | -- Lua is dynamically typed, so there's no point in distinguishing between different dimensions in these. 37 | -- 38 | -- I am aware that a "point" is also just a matrix with one column (or row), and that this could make some of 39 | -- this code more compact. But I'll leave it this way for a bit more clarity. 40 | 41 | -- Point in n-dimensional space. Doubles as a generic array type with some higher level functions. 42 | -- Methods don't modify the objects. 43 | -- 44 | -- Example: 45 | -- p = Point(1, -2, 3) 46 | -- print(p[1]) 47 | -- print(p.size) 48 | -- print(3 * p) 49 | class Point extends ClassFix 50 | -- Possible arguments for constructor: 51 | -- - A collection of numbers: 52 | -- Point(1, 2, 3, 4) 53 | -- - A table 54 | -- Point({1, 2, 3}) 55 | -- - A 1xn or nx1 matrix 56 | -- Point(Matrix({{1, 2, 3, 4}})) 57 | new: (a, ...) => 58 | local coords 59 | if type(a) == "table" 60 | if a.__class == Matrix 61 | if a.width == 1 62 | coords = [r[1] for r in *a] 63 | elseif a.height == 1 64 | coords = a[1] 65 | else 66 | coords = a 67 | else 68 | coords = {a, ...} 69 | 70 | for i, v in ipairs(coords) 71 | @[i] = v 72 | @size = #coords 73 | 74 | x: => @[1] 75 | y: => @[2] 76 | z: => @[3] 77 | 78 | aslist: () => [v for v in *@] 79 | 80 | project: (fr, to) => 81 | if to == nil 82 | to = fr 83 | fr = 1 84 | 85 | return Point([@[i] for i=fr,to]) 86 | 87 | map: (f) => 88 | return @@ [f(v) for v in *@] 89 | 90 | fold: (f, initial) => 91 | val = initial 92 | for c in *@ 93 | val = f(val, c) 94 | return val 95 | 96 | zipWith: (f, p) => 97 | assert(@size == p.size) 98 | return @@ [f(@[i], p[i]) for i=1,@size] 99 | 100 | copy: () => @map(id) 101 | 102 | sum: => @fold(((a, b) -> a + b), 0) 103 | 104 | __eq: (p) => @size == p.size and @dist(p) == 0 105 | 106 | __len: () => @size 107 | 108 | __add: (p, q) -> 109 | if type(p) == "number" 110 | return q\map((a) -> p + a) 111 | elseif type(q) == "number" 112 | return p\map((a) -> a + q) 113 | 114 | if not q.size 115 | return getmetatable(q).__add(p, q) 116 | 117 | return p\zipWith(((a, b) -> a + b), q) 118 | 119 | __unm: => @map((a) -> -a) 120 | 121 | __sub: (p) => @ + (-p) 122 | 123 | __mul: (p, q) -> 124 | if type(p) == "number" 125 | return q\map((a) -> p * a) 126 | elseif type(q) == "number" 127 | return p\map((a) -> a * q) 128 | return p\dot(q) 129 | 130 | __div: (p, q) -> 131 | if type(p) == "number" 132 | return q\map((a) -> p / a) 133 | elseif type(q) == "number" 134 | return p\map((a) -> a / q) 135 | return p\zipWith(((a, b) -> a / b), q) 136 | 137 | __concat: (q) => 138 | p = @ 139 | if type(p) == "number" 140 | p = @@ p 141 | elseif type(q) == "number" 142 | q = @@ q 143 | 144 | if not q.size 145 | return getmetatable(q).__concat(p, q) 146 | 147 | return @@ [(if i <= p.size then p[i] else q[i-p.size]) for i=1,(p.size+q.size)] 148 | 149 | __tostring: => 150 | s = "#{@@__name}(" 151 | for i, c in ipairs(@) 152 | if i > 1 153 | s ..= ", " 154 | s ..= tostring(c) 155 | return s .. ")" 156 | 157 | to: (p) => p - @ 158 | 159 | hadamard_prod: (p) => @zipWith(((a, b) -> a * b), p) 160 | 161 | dot: (p) => @hadamard_prod(p)\sum! 162 | 163 | length: => math.sqrt(@map((a) -> a^2)\sum!) 164 | 165 | dist: (p) => @to(p)\length! 166 | 167 | min: => @fold(math.min, math.huge) 168 | 169 | max: => -((-@)\min!) 170 | 171 | cross: (p) => 172 | assert(@size == 3 and p.size == 3) 173 | return @@(@y! * p\z! - @z! * p\y!, @z! * p\x! - @x! * p\z!, @x! * p\y! - @y! * p\x!) 174 | 175 | -- k-th unit basis vector in n-dimensional space 176 | @unit: (n, k) -> @ [(if i == k then 1 else 0) for i=1,n] 177 | 178 | 179 | -- nxm matrix. Represented as an "array" of rows, represented by Points 180 | class Matrix extends ClassFix 181 | -- Possible arguments for constructor: 182 | -- - A two-dimensional table (table of rows) 183 | -- Matrix({{1, 2}, {3, 4}}) 184 | -- - A point (to turn into an 1xn matrix) 185 | -- Matrix(Point({1, 2, 3})) 186 | -- - A table of points 187 | -- Matrix([Point({1, 2}), Point({3, 4})]) 188 | -- - A collection of points 189 | -- Matrix(Point({1, 2}), Point({3, 4})) 190 | -- Points will be copied first. 191 | new: (entries, ...) => 192 | local rows 193 | if type(entries[1]) == "number" 194 | rows = [Point(e) for e in *{entries, ...}] 195 | elseif entries.__class == Point 196 | rows = {entries, ...} 197 | elseif entries[1].__class == Point 198 | rows = entries 199 | else 200 | rows = [Point(r) for r in *entries] 201 | 202 | for i, v in ipairs(rows) 203 | @[i] = v 204 | 205 | @height = #rows 206 | @width = #rows[1] 207 | 208 | aslist: () => [ r\aslist! for r in *@] 209 | 210 | project: (...) => [ r\project(...) for r in *@ ] 211 | 212 | square: () => @width == @height 213 | 214 | map: (f) => 215 | return @@ [ r\map(f) for r in *@] 216 | 217 | zipWith: (f, p) => 218 | assert(@height == p.height and @width == p.width) 219 | return @@ [ @[i]\zipWith(f, p[i]) for i=1,@height] 220 | 221 | prod: (m) => 222 | assert(@width == m.height) 223 | return @@ [ [Point([@[i][k] * m[k][j] for k=1,@width])\sum! for j=1,m.width] for i=1,@height] 224 | 225 | copy: () => @map(id) 226 | 227 | __eq: (p) => 228 | return false unless @width == p.width and @height == p.height 229 | for i=1,@width 230 | for j=1,@height 231 | return false unless @[i][j] == p[i][j] 232 | return true 233 | 234 | __len: () => @height 235 | 236 | __add: (q) => 237 | p = @ 238 | if type(p) == "number" 239 | return q\map((a) -> p + a) 240 | if type(q) == "number" 241 | return p\map((a) -> a + q) 242 | if p.__class == Point 243 | p = q.__class ([p for i=1,q.height]) 244 | if q.__class == Point 245 | q = p.__class ([q for i=1,p.height]) 246 | return p\zipWith(((a, b) -> a + b), q) 247 | 248 | __unm: => @map((a) -> -a) 249 | 250 | __sub: (p) => @ + (-p) 251 | 252 | __mul: (p, q) -> 253 | if type(p) == "number" 254 | return q\map((a) -> p * a) 255 | elseif type(q) == "number" 256 | return p\map((a) -> a * q) 257 | elseif q.__class == Point 258 | q = (Matrix q)\transpose! 259 | return p\prod(q) 260 | 261 | __div: (p, q) -> 262 | if type(p) == "number" 263 | return q\map((a) -> p / a) 264 | elseif type(q) == "number" 265 | return p\map((a) -> a / q) 266 | return p\zipWith(((a, b) -> a / b), q) 267 | 268 | __concat: (q) => 269 | p = @ 270 | if type(p) == "number" 271 | p = Point(p) 272 | if type(q) == "number" 273 | q = Point(q) 274 | 275 | if p.__class == Point 276 | p = q.__class [p for i=1,q.height] 277 | if q.__class == Point 278 | q = p.__class [q for i=1,p.height] 279 | 280 | return q.__class [p[i] .. q[i] for i=1,p.height] 281 | 282 | __tostring: => 283 | s = "#{@@__name}(\n" 284 | for r in *@ 285 | s ..= "[ " 286 | for j, c in ipairs(r) 287 | if j > 1 288 | s ..= " " 289 | s ..= tostring(c) 290 | s ..= " ]\n" 291 | return s .. ")" 292 | 293 | transpose: () => 294 | @@ [ [@[i][j] for i=1,@height] for j=1,@width] 295 | 296 | -- shorthand for transpose 297 | t: () => @transpose! 298 | 299 | -- For an nxn matrix, returns the (n+1)x(n+1) matrix that leaves the k-th canonical basis vector invariant 300 | -- and acts like the given matrix on the quotient space. 301 | -- Can also take multiple values to do this iteratively. 302 | onSubspace: (k, ...) => 303 | return @copy! if k == nil 304 | coordfun = (i, j) -> 305 | if i == k and j == k 306 | return 1 307 | elseif i == k or j == k 308 | return 0 309 | return @[if i > k then i - 1 else i][if j > k then j - 1 else j] 310 | 311 | return (@@ [ [coordfun(i, j) for j=1,@height+1] for i=1,@width+1])\onSubspace(...) 312 | 313 | 314 | -- Returns the LU decomposition with pivoting, combined in one matrix, together with the permutation 315 | -- The permutation p is given as a permutation dict to be used when computing the preimage. That is, we decompose 316 | -- M = P L U 317 | -- where 318 | -- P[i][j] = 1 iff p[j] = i 319 | lu: => 320 | assert(@square!) 321 | n = @width 322 | m = @aslist! 323 | 324 | p = [i for i=1,n] 325 | 326 | for i=1,n 327 | -- pivoting 328 | maxv = -1 329 | local k 330 | for j=i,n 331 | if math.abs(m[j][i]) > maxv 332 | k = j 333 | maxv = math.abs(m[j][i]) 334 | 335 | m[i], m[k] = m[k], m[i] 336 | p[i], p[k] = p[k], p[i] 337 | 338 | -- LU step 339 | for j=i,n -- R 340 | for k=1,(i-1) 341 | m[i][j] -= m[i][k] * m[k][j] 342 | for j=(i+1),n -- L 343 | for k=1,(i-1) 344 | m[j][i] -= m[j][k] * m[k][i] 345 | m[j][i] /= m[i][i] 346 | 347 | return @@(m), p 348 | 349 | -- Returns the LU decomposition M = P L U with pivoting by returning the three matrices P, L, U. 350 | lu_matrices: => 351 | lu, pt = @lu! 352 | n = @width 353 | 354 | l = @@ [ [(if j < i then lu[i][j] else (if j == i then 1 else 0)) for j=1,n] for i=1,n] 355 | u = @@ [ [(if j >= i then lu[i][j] else 0) for j=1,n] for i=1,n] 356 | p = @@ [ [(if pt[j] == i then 1 else 0) for j=1,n] for i=1,n] 357 | 358 | return p, l, u 359 | 360 | -- If the matrix is an LU decomposition, computes the preimage of y 361 | luPreim: (b, p) => 362 | assert(@square! and @width == #b) 363 | n = @width 364 | b = [b[p[i]] for i=1,#b] unless p == nil 365 | -- forward substitution 366 | z = {} 367 | for i=1,n 368 | z[i] = b[i] 369 | for j=1,(i-1) 370 | z[i] -= @[i][j] * z[j] 371 | 372 | -- backward substitution 373 | x = {} 374 | for ii=1,n 375 | i = n + 1 - ii 376 | x[i] = z[i] 377 | for j=(i+1),n 378 | x[i] -= @[i][j] * x[j] 379 | x[i] /= @[i][i] 380 | 381 | return Point(x) 382 | 383 | preim: (b) => 384 | lu, p = @lu! 385 | return lu\luPreim(b, p) 386 | 387 | det: => 388 | lu = @lu! 389 | return Point([lu[i][i] for i=1,@width])\fold(((a, b) -> a * b), 1) 390 | 391 | inverse: => 392 | lu, p = @lu! 393 | return (@@ [lu\luPreim(Point.unit(@width, k), p) for k=1,@width])\transpose! 394 | 395 | 396 | @diag = (...) -> 397 | diagonal = {...} 398 | diagonal = diagonal[1] if type(diagonal[1]) == "table" 399 | return @@ [ [(if i == j then diagonal[i] else 0) for j=1,#diagonal] for i=1,#diagonal] 400 | 401 | @id = (n) -> 402 | return Matrix [ [(if i == j then 1 else 0) for j=1,n] for i=1,n] 403 | 404 | @rot2d = (phi) -> 405 | return Matrix { 406 | {math.cos(phi), -math.sin(phi)}, 407 | {math.sin(phi), math.cos(phi)}, 408 | } 409 | 410 | 411 | -- transforms each point in the given shape string. The transform argument can either be 412 | -- - a function that takes a 2d Point and returns a 2d Point, or 413 | -- - a 2x2 or 3x3 Matrix. In the case of a 3x3 Matrix, points will be transformed projectively. 414 | -- That is, they'll be given a z coordinate of 1, multiplied by the matrix, and projected back 415 | -- to the z=1 plane. 416 | transformShape = (shape, transform) -> 417 | if type(transform) == "table" and transform.__class == Matrix 418 | if #transform == 2 419 | transform = transform\onSubspace(3) 420 | 421 | mat = transform 422 | transform = (pt) -> 423 | pt = Point(mat * (pt .. 1)) 424 | return (pt / pt\z!)\project(2) 425 | 426 | return shape\gsub("([+-%d.eE]+)%s+([+-%d.eE]+)", (x, y) -> 427 | pt = transform(Point(x, y)) 428 | "#{pt\x!} #{pt\y!}") 429 | 430 | 431 | lib = { 432 | :Point, 433 | :Matrix, 434 | :transformShape, 435 | } 436 | 437 | if haveDepCtrl 438 | lib.version = depctrl 439 | return depctrl\register lib 440 | else 441 | return lib 442 | -------------------------------------------------------------------------------- /modules/arch/Perspective.moon: -------------------------------------------------------------------------------- 1 | haveDepCtrl, DependencyControl, depctrl = pcall require, 'l0.DependencyControl' 2 | 3 | local amath 4 | 5 | if haveDepCtrl 6 | depctrl = DependencyControl { 7 | name: "Perspective", 8 | version: "1.2.0", 9 | description: [[Math functions for dealing with perspective transformations.]], 10 | author: "arch1t3cht", 11 | url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 12 | moduleName: 'arch.Perspective', 13 | { 14 | {"arch.Math", version: "0.1.8", url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 15 | feed: "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json"}, 16 | } 17 | } 18 | 19 | amath = depctrl\requireModules! 20 | else 21 | amath = require"arch.Math" 22 | 23 | {:Point, :Matrix} = amath 24 | 25 | -- compatibility with Lua >= 5.2 26 | unpack = unpack or table.unpack 27 | 28 | 29 | local Quad 30 | 31 | -- Quadrilateral (usually in 2D space) described by its four corners, in clockwise or counter-clockwise direction. 32 | -- Internally, we always use numbering that's counter-clockwise in the cartesian plane, which is clockwise on a 2D screen. 33 | class Quad extends Matrix 34 | new: (...) => 35 | super(...) 36 | assert(@height == 4) 37 | 38 | -- Computes the intersection point of the diagonals. 39 | -- Doubles as a generic function to intersect to lines in 2D space. 40 | midpoint: => 41 | la = Matrix(@[3] - @[1], @[4] - @[2])\transpose!\preim(@[4] - @[1]) 42 | return @[1] + la[1] * (@[3] - @[1]) 43 | 44 | 45 | -------------------- 46 | -- Collection of functions describing the perspective transformation between this quad and a 1x1 square. 47 | -- These were originally computed from cross-ratios and run through Mathematica to combine all the fractions, 48 | -- which makes it work in such "edge" cases as two sides of the quad being parallel. 49 | -- They were then dumped from Mathematica in InputForm and inserted here without much postprocessing, 50 | -- except for sometimes putting common denominators in an extra variable 51 | -------------------- 52 | 53 | -- Helper functions to wrap code dumped from Mathematica 54 | -- returns x1, x2, x3, x4, y1, y2, y3, y4 55 | unwrap: => @[1][1], @[2][1], @[3][1], @[4][1], @[1][2], @[2][2], @[3][2], @[4][2] 56 | 57 | -- translates x1, y1 to 0, 0 and returns x2, x3, x4, y2, y3, y4 58 | unwrap_rel: => 59 | @ = @ - @[1] 60 | return @[2][1], @[3][1], @[4][1], @[2][2], @[3][2], @[4][2] 61 | 62 | 63 | -- Perspective transform mapping the quad to a unit square 64 | xy_to_uv: (xy) => 65 | assert(@width == 2) 66 | x2, x3, x4, y2, y3, y4 = @unwrap_rel! 67 | x, y = unpack(xy - @[1]) 68 | 69 | u = -(((x3*y2 - x2*y3)*(x4*y - x*y4)*(x4*(-y2 + y3) + x3*(y2 - y4) + x2*(-y3 + y4)))/(x3^2*(x4*y2^2*(-y + y4) + y4*(x*y2*(y2 - y4) + x2*(y - y2)*y4)) + x3*(x4^2*y2^2*(y - y3) + 2*x4*(x2*y*y3*(y2 - y4) + x*y2*(-y2 + y3)*y4) + x2*y4*(x2*(-y + y3)*y4 + 2*x*y2*(-y3 + y4))) + y3*(x*x4^2*y2*(y2 - y3) + x2*x4^2*(y2*y3 + y*(-2*y2 + y3)) - x2^2*(x4*y*(y3 - 2*y4) + x4*y3*y4 + x*y4*(-y3 + y4))))) 70 | v = ((x2*y - x*y2)*(x4*y3 - x3*y4)*(x4*(y2 - y3) + x2*(y3 - y4) + x3*(-y2 + y4)))/(x3*(x4^2*y2^2*(-y + y3) + x2*y4*(2*x*y2*(y3 - y4) + x2*(y - y3)*y4) - 2*x4*(x2*y*y3*(y2 - y4) + x*y2*(-y2 + y3)*y4)) + x3^2*(x4*y2^2*(y - y4) + y4*(x2*(-y + y2)*y4 + x*y2*(-y2 + y4))) + y3*(x*x4^2*y2*(-y2 + y3) + x2*x4^2*(2*y*y2 - y*y3 - y2*y3) + x2^2*(x4*y*(y3 - 2*y4) + x4*y3*y4 + x*y4*(-y3 + y4)))) 71 | 72 | return Point(u, v) 73 | 74 | -- Perspective transform mapping a unit square to the quad 75 | uv_to_xy: (uv) => 76 | assert(@width == 2) 77 | x2, x3, x4, y2, y3, y4 = @unwrap_rel! 78 | u, v = unpack(uv) 79 | 80 | d = (x4*((-1 + u + v)*y2 + y3 - v*y3) + x3*(y2 - u*y2 + (-1 + v)*y4) + x2*((-1 + u)*y3 - (-1 + u + v)*y4)) 81 | x = (v*x4*(x3*y2 - x2*y3) + u*x2*(x4*y3 - x3*y4)) / d 82 | y = (v*y4*(x3*y2 - x2*y3) + u*y2*(x4*y3 - x3*y4)) / d 83 | 84 | return Point(x, y) + @[1] 85 | 86 | -- Derivative (i.e. Jacobian) of uv_to_xy at the given point 87 | d_uv_to_xy: (uv) => 88 | assert(@width == 2) 89 | x2, x3, x4, y2, y3, y4 = @unwrap_rel! 90 | u, v = unpack(uv) 91 | 92 | d = (x4*((-1 + u + v)*y2 + y3 - v*y3) + x3*(y2 - u*y2 + (-1 + v)*y4) + x2*((-1 + u)*y3 - (-1 + u + v)*y4))^2 93 | 94 | dxdu = (x2*(x4*y3 - x3*y4)*(x4*((-1 + u + v)*y2 + y3 - v*y3) + x3*(y2 - u*y2 + (-1 + v)*y4) + x2*((-1 + u)*y3 - (-1 + u + v)*y4)) + (x3*y2 - x4*y2 + x2*(-y3 + y4))*(v*x4*(x3*y2 - x2*y3) + u*x2*(x4*y3 - x3*y4))) / d 95 | dxdv = (x4*(x3*y2 - x2*y3)*(x4*((-1 + u + v)*y2 + y3 - v*y3) + x3*(y2 - u*y2 + (-1 + v)*y4) + x2*((-1 + u)*y3 - (-1 + u + v)*y4)) - (x4*(y2 - y3) + (-x2 + x3)*y4)*(v*x4*(x3*y2 - x2*y3) + u*x2*(x4*y3 - x3*y4))) / d 96 | dydu = ((-1 + v)*x3^2*y2*(y2 - y4)*y4 + y3*((-1 + v)*x4^2*y2*(y2 - y3) + v*x2^2*(y3 - y4)*y4 + x2*x4*y2*(-y3 + y4)) + x3*y2*(2*(-1 + v)*x4*y3*y4 - (-1 + 2*v)*x2*(y3 - y4)*y4 + x4*y2*(y3 + y4 - 2*v*y4))) / d 97 | dydv = ((x3*y2 - x2*y3)*y4*(-(x4*y2) - x2*y3 + x4*y3 + x3*(y2 - y4) + x2*y4) + u*(x4^2*y2*y3*(-y2 + y3) + 2*x3*x4*y2*(y2 - y3)*y4 + y4*(2*x2*x3*y2*(y3 - y4) + x3^2*y2*(-y2 + y4) + x2^2*y3*(-y3 + y4)))) / d 98 | 99 | return Matrix({{dxdu, dxdv}, {dydu, dydv}}) 100 | 101 | -- Derivative (i.e. Jacobian) of xy_to_uv at the given point 102 | d_xy_to_uv: (xy) => 103 | assert(@width == 2) 104 | x2, x3, x4, y2, y3, y4 = @unwrap_rel! 105 | x, y = unpack(xy) 106 | 107 | d = (x3*(x4^2*y2^2*(-y + y3) + x2*y4*(2*x*y2*(y3 - y4) + x2*(y - y3)*y4) - 2*x4*(x2*y*y3*(y2 - y4) + x*y2*(-y2 + y3)*y4)) + x3^2*(x4*y2^2*(y - y4) + y4*(x2*(-y + y2)*y4 + x*y2*(-y2 + y4))) + y3*(x*x4^2*y2*(-y2 + y3) + x2*x4^2*(2*y*y2 - y*y3 - y2*y3) + x2^2*(x4*y*(y3 - 2*y4) + x4*y3*y4 + x*y4*(-y3 + y4))))^2 108 | 109 | dudx = ((x3*y2 - x2*y3)*(x4*y2 - x2*y4)*(x4*y3 - x3*y4)*(x4*y*(y2 - y3) + x3*(y - y2)*y4 + x2*(-y + y3)*y4)*(x4*(-y2 + y3) + x3*(y2 - y4) + x2*(-y3 + y4))) / d 110 | dvdx = -((x3*y2 - x2*y3)*(x4*y2 - x2*y4)*(x4*y3 - x3*y4)*(-(x3*x4*y2) + x*x4*(y2 - y3) + x2*x4*y3 + x*(-x2 + x3)*y4)*(x4*(-y2 + y3) + x3*(y2 - y4) + x2*(-y3 + y4))) / d 111 | dudy = ((x3*y2 - x2*y3)*(x4*y2 - x2*y4)*(x4*y3 - x3*y4)*(x4*y2*(y - y3) + x2*y*(y3 - y4) + x3*y2*(-y + y4))*(x4*(y2 - y3) + x2*(y3 - y4) + x3*(-y2 + y4))) / d 112 | dvdy = ((x3*y2 - x2*y3)*(x4*y2 - x2*y4)*(-(x4*y3) + x3*y4)*(x4*(-y2 + y3) + x3*(y2 - y4) + x2*(-y3 + y4))*(x*(x3*y2 - x4*y2 - x2*y3 + x2*y4) + x2*(x4*y3 - x3*y4))) / d 113 | 114 | return Matrix({{dudx, dudy}, {dvdx, dvdy}}) 115 | 116 | rect: (width, height) -> 117 | Quad { 118 | {0, 0}, 119 | {width, 0}, 120 | {width, height}, 121 | {0, height}, 122 | } 123 | 124 | screen_z = 312.5 125 | 126 | an_xshift = { 0, 0.5, 1, 0, 0.5, 1, 0, 0.5, 1 } 127 | an_yshift = { 1, 1, 1, 0.5, 0.5, 0.5, 0, 0, 0 } 128 | 129 | 130 | -- List of tags that affect perspective 131 | relevantTags = {"fontsize", "shear_x", "shear_y", "scale_x", "scale_y", "angle", "angle_x", "angle_y", "origin", "position", "outline", "outline_x", "outline_y", "shadow", "shadow_x", "shadow_y"} 132 | 133 | -- List of tags that are used in perspective. This is the same list as relevant_tags except for outline and shadow, since the single-coordinate versions of those tags are used instead 134 | usedTags = {"fontsize", "shear_x", "shear_y", "scale_x", "scale_y", "angle", "angle_x", "angle_y", "origin", "position", "outline_x", "outline_y", "shadow_x", "shadow_y"} 135 | 136 | -- Takes an ASSFoundation LineContents object and returns its effective tags, but preprocessed 137 | -- for perspective handling. This includes inforcing the relations between \org and \pos as 138 | -- well as those between the various transform tags, as well as warning when the line has tags 139 | -- that would break perspective calulations (\move, certain \t transformations, multiple tag sections, etc). 140 | -- Also needs the ASSFoundation library to be given in its first parameter. This is to prevent this library 141 | -- from depending on ASSFoundation. 142 | -- Returns: 143 | -- - the effective tags table 144 | -- - the line's width (not scaled with \fscx) 145 | -- - the line's height (not scaled with \fscy) 146 | -- - a table of warnings about possibly problematic tags 147 | -- -> each warning is of the form {warning, details} where details may be nil depending on the warning. 148 | prepareForPerspective = (ASS, data) -> 149 | tagvals = data\getEffectiveTags(-1, true, true, true).tags 150 | 151 | width, height = 0, 0 152 | has_text, has_drawing = false, false 153 | 154 | data\callback (section) -> 155 | if section.class == ASS.Section.Text 156 | has_text = true 157 | width, height = data\getTextExtents! 158 | if section.class == ASS.Section.Drawing 159 | has_drawing = true 160 | ext = section\getExtremePoints! 161 | width, height = ext.w, ext.h 162 | 163 | warnings = {} 164 | 165 | table.insert(warnings, {"text_and_drawings"}) if has_text and has_drawing 166 | table.insert(warnings, {"zero_size"}) if has_text and (width == 0 or height == 0) 167 | table.insert(warnings, {"move"}) if data\getPosition().class == ASS.Tag.Move 168 | 169 | -- Width and height can be 0 for drawings 170 | width = math.max(width, 0.01) 171 | height = math.max(height, 0.01) 172 | 173 | width /= (tagvals.scale_x.value / 100) 174 | height /= (tagvals.scale_y.value / 100) 175 | 176 | -- Do some checks for cases that break this script 177 | -- These are a bit more aggressive than necessary (e.g. two tags of the same type in the same section will trigger this detection but not break resampling) 178 | -- but I can't be bothered to be more exact. Users can run ASSWipe before resampling or something. 179 | for tname in *relevantTags 180 | table.insert(warnings, {"multiple_tags", ASS.tagMap[tname].overrideName}) if #data\getTags({tname}) >= 2 181 | 182 | -- Assf doesn't support nested transforms so this code could be much simpler, but a) I only found that out after writing this and b) I guess I can 183 | -- keep this code around in case it ever starts supporting them 184 | checkTransformTags = (section, initial) -> 185 | if not initial 186 | for tname in *relevantTags 187 | table.insert(warnings, {"transform", ASS.tagMap[tname].overrideName}) if #section\getTags({tname}) >= 1 188 | 189 | section\modTags {"transform"}, (tag) -> 190 | checkTransformTags tag.tags, false 191 | tag 192 | 193 | checkTransformTags data, true 194 | 195 | -- Manually enforce the relations between tags 196 | if #data\getTags({"origin"}) == 0 197 | tagvals.origin.x = tagvals.position.x 198 | tagvals.origin.y = tagvals.position.y 199 | for name in *{"outline", "shadow"} 200 | for coord in *{"x", "y"} 201 | cname = "#{name}_#{coord}" 202 | if #data\getTags({cname}) == 0 203 | tagvals[cname].value = tagvals[name].value 204 | 205 | return tagvals, width, height, warnings 206 | 207 | 208 | -- Transforms the given list of points in a relative coordinate system according to the given .ass tags. 209 | -- If no list of points is given, a rectangle with the given dimensions is used. 210 | -- The width and height parameters should contain the raw dimensions of the line to be transformed. These are used for alignment. 211 | -- Thus, when transforming a shape with \an7, width and height can be zero. When transforming text, they should be whatever aegisub.text_extents returned. 212 | -- The table t is supposed to be a table of tags as returned by ASSFoundation, but any table with the same keys and .value or .x/.y 213 | -- fields for the respective tags works. 214 | -- The layoutScale parameter should be set to (script's PlayResY)/(script's LayoutResY), or (scipt's PlayResY)/(video height) if LayoutResY is not present 215 | -- (though in that case you should add it to your script or yell at your user to do so) 216 | transformPoints = (t, width, height, points=nil, layoutScale=1) -> 217 | if points == nil 218 | points = Quad.rect width, height 219 | else 220 | points = Matrix(points) 221 | 222 | scaled_screen_z = screen_z * layoutScale 223 | 224 | pos = Point(t.position.x, t.position.y) 225 | org = Point(t.origin.x, t.origin.y) 226 | 227 | -- Shearing 228 | points *= Matrix({ 229 | {1, t.shear_x.value}, 230 | {t.shear_y.value, 1}, 231 | })\t! 232 | 233 | -- Translate to alignment point 234 | an = t.align.value 235 | points -= Point(width * an_xshift[an], height * an_yshift[an]) 236 | 237 | -- Apply scaling 238 | points *= (Matrix.diag(t.scale_x.value, t.scale_y.value) / 100) 239 | 240 | -- Translate relative to origin 241 | points += pos - org 242 | 243 | -- Rotate ZXY 244 | points ..= 0 245 | points *= Matrix.rot2d(math.rad(-t.angle.value))\onSubspace(3)\t! 246 | points *= Matrix.rot2d(math.rad(-t.angle_x.value))\onSubspace(1)\t! 247 | points *= Matrix.rot2d(math.rad(t.angle_y.value))\onSubspace(2)\t! 248 | 249 | -- Project 250 | points = Matrix [ (scaled_screen_z / (p\z! + scaled_screen_z)) * p\project(2) for p in *points ] 251 | 252 | -- Move to origin 253 | points += org 254 | return points 255 | 256 | 257 | -- Given a quad on screen and the width and height of the text, returns in t (again an ASSFoundation tags table) 258 | -- the tag values that will transform this text to the given quad. 259 | -- The orgMode parameter controls how the value of \org is chosen: 260 | -- orgMode = 1: \org is not changed, i.e. the origin tag passed in the t parameter is not modified 261 | -- orgMode = 2: \org is set to the center of the quad 262 | -- orgMode = 3: \org is chosen in a way that tries to ensure that \fax can be zero, or as close to zero as possible 263 | -- The layoutScale parameter should be set to (script's PlayResY)/(script's LayoutResY), or (scipt's PlayResY)/(video height) if LayoutResY is not present 264 | -- (though in that case you should add it to your script or yell at your user to do so) 265 | -- For the sake of backwards compatibility, orgMode=false is synonymous with orgMode=1 and orgMode=true is synonymous with orgMode=2 . 266 | tagsFromQuad = (t, quad, width, height, orgMode=0, layoutScale=1) -> 267 | quad = Quad(quad) if quad.__class != Quad 268 | scaled_screen_z = layoutScale * screen_z 269 | 270 | -- Find a parallelogram projecting to the quad 271 | z24 = Matrix({ quad[2] - quad[3], quad[4] - quad[3] })\t!\preim(quad[1] - quad[3]) 272 | 273 | if orgMode == 2 or orgMode == true 274 | center = quad\midpoint! 275 | t.origin.x = center\x! 276 | t.origin.y = center\y! 277 | else if orgMode == 3 278 | v2 = quad[2] - quad[1] 279 | v4 = quad[4] - quad[1] 280 | 281 | -- Look for a translation after which the quad will unproject to a rectangle. 282 | -- Specifically, look for a vector t such that this happens after moving q0 to t. 283 | -- The set of such vectors is cut out by the equation a (x^2 + y^2) - b1 x - b2 y + c 284 | -- with the following coefficients. 285 | 286 | a = (1 - z24[1]) * (1 - z24[2]) 287 | b = z24[1] * v2 + z24[2] * v4 - z24[1] * z24[2] * (v2 + v4) 288 | c = z24[1] * z24[2] * v2 * v4 + (z24[1] - 1) * (z24[2] - 1) * scaled_screen_z ^ 2 289 | 290 | -- Our default value for o, which would put \org at the center of the quad. 291 | -- We'll try to find a value for \org that's as close as possible to it. 292 | o = quad[1] - quad\midpoint! 293 | 294 | -- Handle all the edge cases. These can actually come up in practice, like when 295 | -- starting from text without any perspective. 296 | if a == 0 297 | -- If b = 0 we get a trivial or impossible equation, so just keep the previous \org. 298 | if b\length! != 0 299 | -- The equation cuts out a line. Find the point closest to the previous o. 300 | o = o + b * ((c - o\dot(b)) / b\dot(b)) 301 | else 302 | -- The equation cuts out a circle. 303 | -- Complete the square to find center and radius. 304 | circleCenter = b / (2 * a) 305 | sqradius = (b\dot(b) / (4 * a) - c) / a 306 | 307 | if sqradius <= 0 308 | -- This is actually very rare. 309 | org = circleCenter 310 | else 311 | -- Find the point on the circle closest to the current \org. 312 | radius = math.sqrt(sqradius) 313 | center2t = o - circleCenter 314 | if center2t\length! == 0 315 | o = circleCenter + Point(radius, 0) 316 | else 317 | o = circleCenter + center2t / center2t\length! * radius 318 | 319 | org = quad[1] - o 320 | t.origin.x = org\x! 321 | t.origin.y = org\y! 322 | 323 | -- Normalize to center 324 | org = Point(t.origin.x, t.origin.y) 325 | quad -= org 326 | 327 | -- Unproject the quad 328 | zs = Point(1, z24[1], z24\sum! - 1, z24[2]) 329 | quad ..= scaled_screen_z 330 | quad = Matrix.diag(zs) * quad 331 | 332 | -- Normalize so the origin has z=scaled_screen_z 333 | orgla = Matrix({Point(0, 0, scaled_screen_z), quad[1] - quad[2], quad[1] - quad[4]})\t!\preim(quad[1]) 334 | quad /= orgla[1] 335 | 336 | quad -= Matrix[{0, 0, scaled_screen_z} for i=1,4] 337 | 338 | -- Find the rotations 339 | n = (quad[2] - quad[1])\cross(quad[4] - quad[1]) 340 | roty = math.atan(n\x! / n\z!) 341 | roty += math.pi if n\z! < 0 342 | ry = Matrix.rot2d(roty)\onSubspace(2) 343 | n = Point(ry * n) 344 | rotx = math.atan(n\y! / n\z!) 345 | rx = Matrix.rot2d(rotx)\onSubspace(1) 346 | 347 | quad *= ry\t! 348 | quad *= rx\t! 349 | 350 | ab = quad[2] - quad[1] 351 | rotz = math.atan(ab\y! / ab\x!) 352 | rotz += math.pi if ab\x! < 0 353 | rz = Matrix.rot2d(-rotz)\onSubspace(3) 354 | 355 | quad *= rz\t! 356 | 357 | -- We now have a horizontal parallelogram in the 2D plane, so find the shear and the dimensions 358 | ab = quad[2] - quad[1] 359 | ad = quad[4] - quad[1] 360 | rawfax = ad\x! / ad\y! 361 | 362 | quadwidth = ab\length! 363 | quadheight = math.abs(ad\y!) 364 | scalex = quadwidth / width 365 | scaley = quadheight / height 366 | 367 | -- Find \pos 368 | an = t.align.value 369 | pos = org + (quad[1]\project(2) + Point(quadwidth * an_xshift[an], quadheight * an_yshift[an])) 370 | 371 | -- Set all the new tags 372 | t.position.x = pos\x! 373 | t.position.y = pos\y! 374 | t.angle.value = math.deg(-rotz) 375 | t.angle_x.value = math.deg(rotx) 376 | t.angle_y.value = math.deg(-roty) 377 | t.scale_x.value = 100 * scalex 378 | t.scale_y.value = 100 * scaley 379 | t.shear_x.value = rawfax * scaley / scalex 380 | t.shear_y.value = 0 381 | 382 | 383 | lib = { 384 | :Quad, 385 | :an_xshift, 386 | :an_yshift, 387 | :relevantTags, 388 | :usedTags, 389 | :prepareForPerspective 390 | :transformPoints, 391 | :tagsFromQuad, 392 | } 393 | 394 | if haveDepCtrl 395 | lib.version = depctrl 396 | return depctrl\register lib 397 | else 398 | return lib 399 | -------------------------------------------------------------------------------- /modules/arch/Util.moon: -------------------------------------------------------------------------------- 1 | DependencyControl = require 'l0.DependencyControl' 2 | 3 | depctrl = DependencyControl { 4 | name: "Util", 5 | version: "0.1.0", 6 | description: [[Utility functions used in some of my scripts]], 7 | author: "arch1t3cht", 8 | url: "https://github.com/TypesettingTools/arch1t3cht-Aegisub-Scripts", 9 | moduleName: 'arch.Util', 10 | { 11 | {"a-mo.Line", version: "1.5.3", url: "https://github.com/TypesettingTools/Aegisub-Motion", 12 | feed: "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json"}, 13 | {"l0.ASSFoundation", version: "0.5.0", url: "https://github.com/TypesettingTools/ASSFoundation", 14 | feed: "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 15 | } 16 | } 17 | 18 | Line, ASS = depctrl\requireModules! 19 | 20 | 21 | -- rounds a ms timestamp to cs just like Aegisub does 22 | round_to_cs = (time) -> 23 | (time + 5) - (time + 5) % 10 24 | 25 | 26 | -- gets the exact starting timestamp of a given frame, 27 | -- unlike aegisub.frame_from_ms, which returns a timestamp in the 28 | -- middle of the frame suitable for a line's start time. 29 | exact_ms_from_frame = (frame) -> 30 | frame += 1 31 | 32 | ms = aegisub.ms_from_frame(frame) 33 | while true 34 | new_ms = ms - 1 35 | if new_ms < 0 or aegisub.frame_from_ms(new_ms) != frame 36 | break 37 | 38 | ms = new_ms 39 | 40 | return ms - 1 41 | 42 | 43 | -- line2fbf function, modified from a function by PhosCity 44 | line2fbf = (sourceData, cleanLevel = 3) -> 45 | line, effTags = sourceData.line, (sourceData\getEffectiveTags -1, true, true, false).tags 46 | -- Aegisub will never give us timestamps that aren't rounded to centiseconds, but lua code might. 47 | -- Explicitly round to centiseconds just to be sure. 48 | startTime = round_to_cs line.start_time 49 | startFrame = line.startFrame 50 | endFrame = line.endFrame 51 | 52 | -- Tag Collection 53 | local fade 54 | -- Fade 55 | for tag in *{"fade_simple", "fade"} 56 | fade = sourceData\getTags(tag, 1)[1] 57 | break if fade 58 | 59 | -- Transform 60 | transforms = sourceData\getTags "transform" 61 | tagSections = {} 62 | sectionEffTags = {} 63 | sourceData\callback ((section, _, i, j) -> 64 | tagSections[i] = j 65 | sectionEffTags[i] = (section\getEffectiveTags true).tags 66 | ), ASS.Section.Tag 67 | 68 | -- Fbfing 69 | fbfLines = {} 70 | for frame = startFrame, endFrame-1 71 | newLine = Line sourceData.line, sourceData.line.parentCollection 72 | newLine.start_time = aegisub.ms_from_frame(frame) 73 | newLine.end_time = aegisub.ms_from_frame(frame + 1) 74 | data = ASS\parse newLine 75 | now = exact_ms_from_frame(frame) - startTime 76 | 77 | -- Move 78 | move = effTags.move 79 | if move and not move.startPos\equal move.endPos 80 | t1, t2 = move.startTime.value, move.endTime.value 81 | 82 | -- Does assf handle this for us already? Who knows, certainly not me! 83 | t1 or= 0 84 | t2 or= 0 85 | 86 | t1, t2 = t2, t1 if t1 > t2 87 | 88 | if t1 <= 0 and t2 <= 0 89 | t1 = 0 90 | t2 = line.duration 91 | 92 | local k 93 | if now <= t1 94 | k = 0 95 | elseif now >= t2 96 | k = 1 97 | else 98 | k = (now - t1) / (t2 - t1) 99 | 100 | finalPos = move.startPos\lerp(move.endPos, k) 101 | data\removeTags "move" 102 | data\replaceTags {ASS\createTag "position", finalPos} 103 | 104 | -- Transform 105 | if #transforms > 0 106 | currValue = {} 107 | data\removeTags "transform" 108 | for tr in *transforms 109 | sectionIndex = tr.parent.index 110 | tagIndex = tagSections[sectionIndex] 111 | 112 | t1 = tr.startTime\get! 113 | t2 = tr.endTime\get! 114 | 115 | t2 = line.duration if t2 == 0 116 | 117 | accel = tr.accel\get! or 1 118 | 119 | local k 120 | if now < t1 121 | k = 0 122 | elseif now >= t2 123 | k = 1 124 | else 125 | k = ((now - t1) / (t2 - t1))^accel 126 | 127 | for tag in *tr.tags\getTags! 128 | -- FIXME this still breaks in a case like \t(\frx10)\frx20\t(\frx30) 129 | -- but that's extremely niche so I'm not fixing it now 130 | tagname = tag.__tag.name 131 | currValue[tagIndex] or= {} 132 | currValue[tagIndex][tagname] or= sectionEffTags[sectionIndex][tagname] 133 | local finalValue 134 | 135 | if tag.class == ASS.Tag.Color 136 | finalValue = currValue[tagIndex][tagname]\copy! 137 | for channel in *{"r", "g", "b"} 138 | finalValue[channel] = finalValue[channel]\lerp tag[channel], k 139 | elseif tag.class == ASS.Tag.ClipRect 140 | -- ClipRect\lerp exists but does not return the resulting clip. If and when this gets fixed, this can be removed 141 | finalValue = currValue[tagIndex][tagname]\copy! 142 | for pt in *{"topLeft", "bottomRight"} 143 | finalValue[pt] = finalValue[pt]\lerp tag[pt], k 144 | else 145 | finalValue = currValue[tagIndex][tagname]\lerp tag, k 146 | 147 | data\replaceTags finalValue, tagIndex, tagIndex, true 148 | currValue[tagIndex][tagname] = finalValue 149 | 150 | -- Fade 151 | if fade 152 | local a1, a2, a3, t1, t2, t3, t4 153 | if fade.__tag.name == "fade_simple" 154 | a1, a2, a3 = 255, 0, 255 155 | t1, t4 = -1, -1 156 | t2, t3 = fade.inDuration\getTagParams!, fade.outDuration\getTagParams! 157 | else 158 | a1, a2, a3, t1, t2, t3, t4 = fade\getTagParams! 159 | 160 | if t1 == -1 and t4 == -1 161 | t1 = 0 162 | t4 = line.duration 163 | t3 = t4 - t3 164 | 165 | local fadeVal 166 | if now < t1 167 | fadeVal = a1 168 | elseif now < t2 169 | k = (now - t1)/(t2 - t1) 170 | fadeVal = a1 * (1 - k) + a2 * k 171 | elseif now < t3 172 | fadeVal = a2 173 | elseif now < t4 174 | k = (now - t3)/(t4 - t3) 175 | fadeVal = a2 * (1 - k) + a3 * k 176 | else 177 | fadeVal = a3 178 | 179 | data\removeTags {"fade", "fade_simple"} 180 | 181 | -- Insert all alpha tags so we can modify them later 182 | -- Don't bother with checking if they exist already, cleanTags will do that for us later 183 | alphaTags = data\getDefaultTags!\filterTags [ "alpha#{k}" for k=1,4 ] 184 | if alphaTags.tags.alpha1.value == alphaTags.tags.alpha2.value and alphaTags.tags.alpha1.value == alphaTags.tags.alpha3.value and alphaTags.tags.alpha1.value == alphaTags.tags.alpha4.value 185 | alphaTags = {ASS\createTag "alpha", alphaTags.tags.alpha1.value} 186 | 187 | data\insertTags alphaTags, 1, 1 188 | 189 | data\modTags {"alpha", "alpha1", "alpha2", "alpha3", "alpha4"}, ((tag) -> 190 | tag.value = tag.value - (tag.value * fadeVal - 0x7F) / 0xFF + fadeVal 191 | tag.value = math.max(0, math.min(255, tag.value)) 192 | ) if fadeVal > 0 193 | 194 | data\cleanTags cleanLevel 195 | data\commit! 196 | table.insert fbfLines, newLine 197 | 198 | return fbfLines 199 | 200 | 201 | lib = { 202 | :round_to_cs, 203 | :exact_ms_from_frame, 204 | :line2fbf, 205 | } 206 | 207 | lib.version = depctrl 208 | return depctrl\register lib 209 | --------------------------------------------------------------------------------