├── .drone.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md ├── blog_test.go ├── concurrency_test.go ├── core.go ├── ffmpegw.go ├── ffmpegw_test.go ├── github_deploy_key.enc ├── main.go ├── res_installer.go ├── res_installer_test.go ├── runner.conf ├── scaner.go ├── scriptw ├── server.go ├── tasks.go └── utils.go /.drone.yml: -------------------------------------------------------------------------------- 1 | workspace: 2 | base: /go 3 | path: src/github.com/Hentioe/sorry-generator 4 | 5 | pipeline: 6 | build: 7 | image: golang 8 | commands: 9 | - go get -u github.com/golang/dep/cmd/dep 10 | - dep ensure 11 | - ./scriptw pack 12 | 13 | docker: 14 | image: plugins/docker 15 | repo: bluerain/sorry-generator 16 | auto_tag: true 17 | dockerfile: Dockerfile 18 | secrets: [ docker_username, docker_password ] 19 | volumes: 20 | - /var/run/docker.sock:/var/run/docker.sock 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | dist/ 4 | build/ 5 | resources/ 6 | assets/ 7 | tmp/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.10.x 4 | before_install: 5 | - openssl aes-256-cbc -K $encrypted_b0ccc0a83fd8_key -iv $encrypted_b0ccc0a83fd8_iv -in 6 | github_deploy_key.enc -out github_deploy_key -d 7 | - chmod 600 github_deploy_key 8 | - eval $(ssh-agent -s) 9 | - ssh-add github_deploy_key 10 | - go get -u github.com/golang/dep/cmd/dep 11 | install: 12 | - dep ensure 13 | script: 14 | - ./scriptw pack 15 | deploy: 16 | provider: releases 17 | api_key: 18 | secure: GKDdAPPtvmwR+rs2df9hWbBRTrXzpfFcdFpJOrMjRHtfMYfyjqYI2Quc64TKhsI4/21G35JCvAfE1RB/8DaTl37n1LgwiCb2V+Om6hG/V8FxtCbGKQN6vIJSpSz821k8knO07T4tlSNguGKcIrFFx8bQWilApQEyj97MGHKZMtz3PN+j7o/qubGGY6UHW47yh4qINnkuDBh4untdc/4QBF5g8bQPZ74qOP0/eW9g35y047BlUOq+UOUjG3uTQ4Kh1F8avP8PrJrLcw7B2oBDiP1Pu82kpCkfoy8QKa+RrO544ZUmQuPt6Ge5xrcrwbJOL8/HGjBdQdLBZLSTYxYOabceGfJacAUCNvuucr7hGzmb3JlLQVaXZ1nAprK9VWyx6tftnvPgzlx1iQTfEhnYuHHBCG2zD3/b/+2emH/w/GHyEtuUU7pHYelouH7rSOT3rL9nVYbKdNe7Nm1AFUMMbT3PdtJ1smqf+kaJzXPnAwi22BS2vzopwc1hbKRDwT0IWs5e1RNjThr6XQ20J5xioG2fKaOgOJsV6RSYwwMJRzZO88HRY8FMSPx9rxoemFXrbmEMzy8Jrfo+x9ouoeB3Av5xKzbAEcNKN93STqmp2fkqiJmLSu5YtkEWO6IWW6yx6ilIWsh4f6ttV23kXHimBfGnLrnUunJ7kiWrgKESa2U= 19 | overwrite: true 20 | name: $TRAVIS_TAG 21 | file_glob: true 22 | file: "build/*.tar.gz" 23 | on: 24 | tags: true 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jrottenberg/ffmpeg:3.3 2 | 3 | 4 | ARG FILE_NAME=sorry-gen.tar.gz 5 | ARG DIST_DIR=/data/dist 6 | ARG TMP_DIR=/data/tmp 7 | 8 | 9 | COPY "build/$FILE_NAME" /data/ 10 | 11 | 12 | WORKDIR /data 13 | 14 | 15 | RUN apt-get install -y ttf-wqy-microhei \ 16 | && mkdir -p $DIST_DIR \ 17 | && tar -zxvf $FILE_NAME \ 18 | && ln -s /data/sorry-gen /usr/bin/sorry-gen \ 19 | && rm "/data/$FILE_NAME" \ 20 | && apt-get autoremove -y \ 21 | && rm -rf /var/lib/apt/lists/* \ 22 | && rm -rf /var/lib/apt/lists/partial/* 23 | 24 | 25 | EXPOSE 8080 26 | 27 | 28 | VOLUME $TMP_DIR 29 | 30 | 31 | ENTRYPOINT ["sorry-gen"] 32 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "github.com/gin-contrib/sse" 7 | packages = ["."] 8 | revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" 9 | 10 | [[projects]] 11 | branch = "master" 12 | name = "github.com/gin-gonic/gin" 13 | packages = [ 14 | ".", 15 | "binding", 16 | "json", 17 | "render" 18 | ] 19 | revision = "bd4f73af679e7d645f6d0277258fa360eda96f2d" 20 | 21 | [[projects]] 22 | name = "github.com/go-resty/resty" 23 | packages = ["."] 24 | revision = "f8815663de1e64d57cdd4ee9e2b2fa96977a030e" 25 | version = "v1.4" 26 | 27 | [[projects]] 28 | name = "github.com/golang/protobuf" 29 | packages = ["proto"] 30 | revision = "5a0f697c9ed9d68fef0116532c6e05cfeae00e55" 31 | 32 | [[projects]] 33 | name = "github.com/json-iterator/go" 34 | packages = ["."] 35 | revision = "ca39e5af3ece67bbcda3d0f4f56a8e24d9f2dad4" 36 | version = "1.1.3" 37 | 38 | [[projects]] 39 | name = "github.com/mattn/go-isatty" 40 | packages = ["."] 41 | revision = "57fdcb988a5c543893cc61bce354a6e24ab70022" 42 | 43 | [[projects]] 44 | name = "github.com/modern-go/concurrent" 45 | packages = ["."] 46 | revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" 47 | version = "1.0.3" 48 | 49 | [[projects]] 50 | name = "github.com/modern-go/reflect2" 51 | packages = ["."] 52 | revision = "1df9eeb2bb81f327b96228865c5687bc2194af3f" 53 | version = "1.0.0" 54 | 55 | [[projects]] 56 | name = "github.com/ugorji/go" 57 | packages = ["codec"] 58 | revision = "c88ee250d0221a57af388746f5cf03768c21d6e2" 59 | 60 | [[projects]] 61 | branch = "master" 62 | name = "golang.org/x/net" 63 | packages = [ 64 | "idna", 65 | "publicsuffix" 66 | ] 67 | revision = "640f4622ab692b87c2f3a94265e6f579fe38263d" 68 | 69 | [[projects]] 70 | branch = "master" 71 | name = "golang.org/x/sys" 72 | packages = ["unix"] 73 | revision = "2281fa97ef7b0c26324634d5a22f04babdac8713" 74 | 75 | [[projects]] 76 | name = "golang.org/x/text" 77 | packages = [ 78 | "collate", 79 | "collate/build", 80 | "internal/colltab", 81 | "internal/gen", 82 | "internal/tag", 83 | "internal/triegen", 84 | "internal/ucd", 85 | "language", 86 | "secure/bidirule", 87 | "transform", 88 | "unicode/bidi", 89 | "unicode/cldr", 90 | "unicode/norm", 91 | "unicode/rangetable" 92 | ] 93 | revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" 94 | version = "v0.3.0" 95 | 96 | [[projects]] 97 | name = "gopkg.in/go-playground/validator.v8" 98 | packages = ["."] 99 | revision = "5f57d2222ad794d0dffb07e664ea05e2ee07d60c" 100 | version = "v8.18.1" 101 | 102 | [[projects]] 103 | name = "gopkg.in/yaml.v2" 104 | packages = ["."] 105 | revision = "a5b47d31c556af34a302ce5d659e6fea44d90de0" 106 | 107 | [solve-meta] 108 | analyzer-name = "dep" 109 | analyzer-version = 1 110 | inputs-digest = "246081390a4d34ea4126f5d700342d9985d3b1b3117a4a6e9281eba5c636f016" 111 | solver-name = "gps-cdcl" 112 | solver-version = 1 113 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | [prune] 28 | go-tests = true 29 | unused-packages = true 30 | 31 | [[constraint]] 32 | name = "github.com/gin-gonic/gin" 33 | branch = "master" 34 | 35 | [[constraint]] 36 | name = "github.com/go-resty/resty" 37 | version = "1.4.0" 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 绅士喵 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 | # 此项目停止维护已久,请看此 2 | 3 | 1. 请访问重置版本:[Hentioe/zhenxiang](https://github.com/Hentioe/zhenxiang) 4 | 1. 重置版本将继续提供免费的公共服务:) 5 | 6 | # sorry-generator 7 | 8 | ![Travis](https://travis-ci.org/Hentioe/sorry-generator.svg?branch=master) 9 | ![GitHub release](https://img.shields.io/github/release/Hentioe/sorry-generator.svg) 10 | ![Docker Automated build](https://img.shields.io/docker/build/bluerain/sorry-generator.svg) 11 | 12 | 13 | ### 说明 14 | 15 | 本项目为`Sorry-为所欲为`系列视频/GIF 生成器,配套前端:https://sorry.bluerain.io 16 | 17 | PS:灵感和部分资源模板来自 [xtyxtyx/sorry](https://github.com/xtyxtyx/sorry) 感谢:) 18 | 19 | #### 使用 20 | 21 | 在有 Docker 的系统上直接执行下列命令即可(注意端口映射和挂载目录): 22 | 23 | ```` bash 24 | # 创建 sorry-tenerator 容器的 VOLUME 25 | docker volume create tmp-sorry-gen 26 | # 启动容器 27 | docker run -ti --name sorry-gen \ 28 | -d -p 8080:8080 --restart=always \ 29 | -v tmp-sorry-gen:/data/tmp 30 | -v /data/apps/sorry-generator/resources:/data/resources \ 31 | -v /data/apps/sorry-generator/dist:/data/dist \ 32 | bluerain/sorry-generator 33 | ```` 34 | 35 | 附加解释:容器在启动时会持久化 `/data/tmp` 中的文件到 VOLUME,当前这个目录会存放通过上传接口上传的资源包。 36 | 37 | 38 | 程序默认绑定到 `:8080`,以 `test` 模式启动,若要更改需要手动添加 CLI 参数: 39 | 40 | ```` 41 | ./sorry-gen -bind :80 -mode release 42 | ```` 43 | 44 | 容器启动同样的直接将参数加在镜像后面。 45 | 46 | 47 | 注意:从 0.3 版本开始模板资源不会集成在项目或者 Docker 镜像中,需要自行安装【看[这里](#安装资源)】。 48 | 49 | POST 以下数据到 `http://localhost:8080/generate/sorry/mp4`: 50 | 51 | ```` 52 | {"sentences":["第一句","第二句","第三句","第四句","第五句","第六句","第七句","第八句","第九句"]} 53 | ```` 54 | 55 | 成功会返回: 56 | ```` 57 | { 58 | "hash": "74c6157d5dec218191835252aabda749" 59 | } 60 | ```` 61 | 62 | 63 | 同时会在 /data/apps/sorry-generator/dist 目录下生成对应 hash 作为文件名的文件(没有后缀的为 ass 字幕文件)。 64 | 65 | 注:修改 generate API 的最后一个 path 参数 mp4 为 gif 即产生 gif 文件。修改 sorry 为其它资源(例如王境泽:wangjingze)则产生相对应的资源。 66 | 67 | 假设你这样配置 nginx: 68 | 69 | ```` 70 | server { 71 | listen 80; 72 |        server_name your.domain; 73 | 74 | location / { 75 | root /data/apps/sorry-generator; 76 | index index.html; 77 | } 78 | } 79 | ```` 80 | 那么就可以直接提供生成文件的直链了:http://your.domain/dist/{hash}.[mp4|gif] 81 | 82 | 83 | 对模板资源的数据进行查询: 84 | 85 | 我的前端(或者其它程序)该怎么知道某个资源有多少条字幕句子? 86 | 87 | GET 访问首页 `http://localhost:4008`: 88 | 89 | ```` 90 | { 91 | "res": [ 92 | { 93 | "tpl_key": "dagong", 94 | "name": "窃格瓦拉-打工是不可能……", 95 | "sentences": [], 96 | "sentences_count": 6 97 | }, 98 | { 99 | "tpl_key": "sorry", 100 | "name": "为所欲为", 101 | "sentences": [], 102 | "sentences_count": 9 103 | }, 104 | { 105 | "tpl_key": "wangjingze", 106 | "name": "王境泽-真香", 107 | "sentences": [], 108 | "sentences_count": 4 109 | } 110 | ], 111 | "res_count": 3 112 | } 113 | ```` 114 | 会得到一个 res 数组,其中 tpl_key 就是模板名称,也就是上面的 sorry。sentences_count 表示有多少条字幕(需要输入多少句子)。sentences 数组是预设在程序中的默认字幕(用处例如提供前端输入框默认的 plachholder 的值)。以上所有数据都是程序扫描资源目录产生的结果,没有任何数据库成分。所以只要添加新的资源模板,API 结果会自动变更。 115 | 116 | 也可以 GET 访问 `http://localhost:4008/info/{tpl_key}` 对单独的资源进行数据查询: 117 | 118 | ```` 119 | { 120 | "tpl_key": "sorry", 121 | "name": "为所欲为", 122 | "sentences": [], 123 | "sentences_count": 9 124 | } 125 | ```` 126 | 127 | 资源目录结构说明(以 resources 为根): 128 | 129 | ```` 130 | . 131 | └── template 132 | ├── dagong # 模板 KEY(API 中 tlp_key 的参数即是目录的名称) 133 |    ├── name # 模板显示名称(文本文件),自动生成 134 |    ├── sentences # 预设字幕(文本文件,每一行表示一句字幕),自动生成 135 |    ├── template.ass # 字幕模板,由原始字幕文件自动转换而成 136 |    └── template.mp4 # 视频素材模板(实际上就是无字幕的原视频) 137 | ```` 138 | 139 | 上传资源包 API(将 res.zip 放置在 ./assets 目录中): 140 | 141 | ```` 142 | curl -X POST http://localhost:8080/upload/res \ 143 | -F "file=@./assets/res.zip" \ 144 | -H "Content-Type: multipart/form-data" 145 | ```` 146 | 147 | 上传完成后会自动进行资源包的安装。安装成功会返回安装生成的文件列表,例如: 148 | 149 | ```` 150 | { 151 | "make_files": [ 152 | "resources/template", 153 | "resources/template/sorry", 154 | "resources/template/sorry/template.ass", 155 | "resources/template/sorry/template.mp4", 156 | "resources/template/lese", 157 | "resources/template/lese/template.ass", 158 | "resources/template/lese/template.mp4", 159 | "resources/template/wangjingze", 160 | "resources/template/wangjingze/template.ass", 161 | "resources/template/wangjingze/template.mp4", 162 | "resources/template/dagong", 163 | "resources/template/dagong/template.ass", 164 | "resources/template/dagong/template.mp4" 165 | ] 166 | } 167 | ```` 168 | 169 | 如果安装的资源包中的资源已经存在,则不会生成任何文件(在安装资源包章节有详细描述)。假设上传的资源包中仅仅只有一个 sorry 资源, 170 | 在已经存在 sorry 的情况下,API 会返回一个空的 make_files。 171 | 172 | 异步任务和并发限制: 173 | 174 | ```` 175 | ./sorry-gen -cl 176 | ```` 177 | cl 参数即 Concurrency limits(并发限制),默认限制为 CPU 数量。需要注意的是,此限制并不对 `/generate/{tpl_key}/{res_type}` API 生效。这个参数影响的是生成异步任务的 API: `/task/generate/{tpl_key}`。 178 | 179 | 使用方式: 180 | 181 | POST `http://localhost:8080/task/generate/sorry` 182 | 183 | ```` 184 | {"sentences":["第一句","第二句","第三句","第四句","第五句","第六句","第七句","第八句","第九句"]} 185 | ```` 186 | 187 | 会立即返回: 188 | 189 | ```` 190 | { 191 | "hash": "776168419d55d4fe68792a73f6450791", 192 | "state": "waiting" 193 | } 194 | ```` 195 | 196 | hash 表示产生的任务 ID(同时也表示产生的资源名称),state 表示当前任务状态。一般来讲调用此 API 的 state 状态都是 waiting,因为此 API 只会创建生成任务并立即返回资源 ID 并不会等到任务执行完毕才返回。 197 | 198 | 根据上面产生的 ID 来获取最新的任务状态: 199 | 200 | GET `http://localhost:8080/task/generate/776168419d55d4fe68792a73f6450791` 201 | 202 | ```` 203 | { 204 | "hash": "776168419d55d4fe68792a73f6450791", 205 | "state": "completed" 206 | } 207 | ```` 208 | 209 | 当 state 为 completed 时,任务已经执行结束了,而不再是创建时的等待状态,这时候相应资源已经生成(包含 .gif 和 .mp4)完成,可以直接根据 ID 下载。 210 | 211 | 所有状态常量: 212 | 213 | ```` golang 214 | const ( 215 | // StateWaiting 等待状态(添加后默认) 216 | StateWaiting = "waiting" 217 | // StateCompleted 完成状态 218 | StateCompleted = "completed" 219 | // StateError 失败状态 220 | StateError = "failed" 221 | // StateNone 空状态(没有构建任务) 222 | StateNone = "none" 223 | ) 224 | ```` 225 | 226 | 添加对异步任务和对其的并发限制支持的目的是,当遇到大量用户并发使用的场景的时候可以通过异步任务 API 解决响应延迟以及服务器资源紧张问题。 227 | 228 | 229 | 附加说明: 230 | 231 | * 为什么不加入前端? 232 | 233 | 因为这种东西本来就没必要限制为 Web 前端啊…… 需要前端自己写个静态页面即可。实际上应该将它视作任何 Programmably 项目的后端,例如各种平台的 Bot 234 | 235 | ### 安装资源 236 | 237 | 除了通过 `/upload/res` API 上传以外,还可以在本地执行命令:`./sorry-gen -i res.zip`完成对资源包的安装,资源包的结构见上述说明。资源包中的任何文件都不会对已存在的资源文件进行替换,如果要更新指定资源请先删除相关目录再执行安装。 238 | 239 | 240 | 在手动编译运行的情况下,默认是没有资源包的,你可以拉取并安装我的资源包: 241 | 242 | ```` 243 | wget https://dl.bluerain.io/res.zip 244 | ./sorry-gen -i res.zip 245 | ```` 246 | 247 | 同样的,使用 sorry-generator 的 Docker 容器也可以这样安装资源包: 248 | 249 | ```` 250 | docker run -ti --rm -v $PWD/res.zip:/data/res.zip \ 251 | -v $PWD/resources:/data/resources bluerain/sorry-generator \ 252 | -i res.zip 253 | ```` 254 | 255 | 如果你要创建可安装的资源包,需要遵循与以下标准: 256 | 257 | 1. 以 template 目录为根 258 | 2. 必须存在 template.mp4 和 template.ass 文件 259 | 260 | 假设你创建的安装包目录结构是这样的: 261 | 262 | ```` 263 | . 264 | └── template 265 | └── sorry 266 | ├── template.ass 267 | └── template.mp4 268 | ```` 269 | template.ass 内容为: 270 | 271 | ```` 272 | [Script Info] 273 | ; Script generated by Aegisub 3.2.2 274 | ; http://www.aegisub.org/ 275 | Title: 为所欲为 276 | ScriptType: v4.00+ 277 | WrapStyle: 0 278 | ScaledBorderAndShadow: yes 279 | YCbCr Matrix: TV.601 280 | PlayResX: 300 281 | PlayResY: 168 282 | 283 | [Aegisub Project Garbage] 284 | Audio File: template.mp4 285 | Video File: template.mp4 286 | Video AR Mode: 4 287 | Video AR Value: 1.781250 288 | Video Zoom Percent: 2.000000 289 | Active Line: 8 290 | Video Position: 25 291 | 292 | [V4+ Styles] 293 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 294 | Style: sorry,WenQuanYi Micro Hei,23,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1.1,0.5,2,5,5,5,1 295 | 296 | [Events] 297 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 298 | Dialogue: 0,0:00:01.18,0:00:01.56,sorry,,0,0,0,,好啊 299 | Dialogue: 0,0:00:03.18,0:00:04.43,sorry,,0,0,0,,就算你是一流程序员 300 | Dialogue: 0,0:00:05.31,0:00:07.43,sorry,,0,0,0,,写出来的代码再完美 301 | Dialogue: 0,0:00:07.56,0:00:09.93,sorry,,0,0,0,,我说这是 BUG 它就是 BUG 302 | Dialogue: 0,0:00:10.06,0:00:11.56,sorry,,0,0,0,,毕竟我是用户 303 | Dialogue: 0,0:00:11.93,0:00:13.06,sorry,,0,0,0,,你害我加班啊 304 | Dialogue: 0,0:00:13.81,0:00:16.31,sorry,,0,0,0,,sorry 我就喜欢看程序猿加班 305 | Dialogue: 0,0:00:18.06,0:00:19.56,sorry,,0,0,0,,以后天天找他 BUG 306 | Dialogue: 0,0:00:19.60,0:00:21.60,sorry,,0,0,0,,天天找 天天找 307 | ```` 308 | 309 | 将上述目录打包以后进行安装,会在 resources/template 中产生这样的文件结构(以 sorry 为根的视角): 310 | 311 | ```` 312 | . 313 | └── sorry 314 | ├── name 315 | ├── sentences 316 | ├── template.ass 317 | └── template.mp4 318 | ```` 319 | 320 | template.ass 的内容为: 321 | 322 | ```` 323 | # 上面的内容省略…… 324 | [Events] 325 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 326 | Dialogue: 0,0:00:01.18,0:00:01.56,sorry,,0,0,0,,{{ index .sentences 0 }} 327 | Dialogue: 0,0:00:03.18,0:00:04.43,sorry,,0,0,0,,{{ index .sentences 1 }} 328 | Dialogue: 0,0:00:05.31,0:00:07.43,sorry,,0,0,0,,{{ index .sentences 2 }} 329 | Dialogue: 0,0:00:07.56,0:00:09.93,sorry,,0,0,0,,{{ index .sentences 3 }} 330 | Dialogue: 0,0:00:10.06,0:00:11.56,sorry,,0,0,0,,{{ index .sentences 4 }} 331 | Dialogue: 0,0:00:11.93,0:00:13.06,sorry,,0,0,0,,{{ index .sentences 5 }} 332 | Dialogue: 0,0:00:13.81,0:00:16.31,sorry,,0,0,0,,{{ index .sentences 6 }} 333 | Dialogue: 0,0:00:18.06,0:00:19.56,sorry,,0,0,0,,{{ index .sentences 7 }} 334 | Dialogue: 0,0:00:19.60,0:00:21.60,sorry,,0,0,0,,{{ index .sentences 8 }} 335 | ```` 336 | sentences 的内容为: 337 | 338 | ```` 339 | 好啊 340 | 就算你是一流程序员 341 | 写出来的代码再完美 342 | 我说这是 BUG 它就是 BUG 343 | 毕竟我是用户 344 | 你害我加班啊 345 | sorry 我就喜欢看程序猿加班 346 | 以后天天找他 BUG 347 | 天天找 天天找 348 | ```` 349 | 350 | name 的内容为: 351 | 352 | ```` 353 | 为所欲为 354 | ```` 355 | 356 | 可以发现,安装后的资源和原始资源包解压的区别在于: 357 | 358 | 1. template.ass 文件从原始字幕文件转换为模板字幕文件 359 | 2. 从原始字幕内容中提取的每一条字幕内容被持久化存储在了 sentences 文件中 360 | 3. 从原始字幕文件内容中提取的 Title 属性的值被持久化储存在了 name 文件中 361 | 362 | 只有经过安装的原始资源才能被程序正确的读取,原始资源是无法直接解压使用的。这样做的目的是方便对资源的创建, 363 | 因为在经过安装步骤之前需要手动创建字幕模板,是很别扭的。还要手动创建 name 和 sentences 文件这些跟资源无关的内容。 364 | 而安装功能可以直接使用最原始的资源(原始视频 + 原始字幕)。 365 | 366 | PS: 有关视频字幕的制作建议了解一下 [Aegisub](http://www.aegisub.org/) 软件。 367 | 368 | 369 | ### 申请添加 370 | 371 | 理论上已经不需要申请加入新的模板资源,因为你可以自行上传。不过如果下载和剪切视频以及制作字幕对你而言仍然十分有难度的话 372 | 373 | 你可以用 Issue 投稿: 374 | 375 | 1. 标题为「建议添加 XX」。内容附上视频链接(如果是下载链接更好)、开始-结束时间段。 376 | 2. 标题为「希望添加 XX」。内容为视频片段的简短描述,上传视频附件(尺寸无所谓,我会自行会压缩) 377 | 378 | 第一种 Issue 会根据视频片段的热门程度、下载复杂度来决定是否添加,而第二种视频资源已经准备好的 Issue 有极大的可能会直接添加(精力有限)。 379 | 380 | ### 版本功能计划 381 | 382 | - [x] v0.1: 实现基本功能 383 | - [x] v0.2: 添加基于对模板资源扫描产生数据的查询相关的 API 384 | - [x] v0.3: 程序本体和模板资源分离 385 | - [x] v0.4: 提供上传接口并持久化储存新增的模板(固定结构的压缩包资源) 386 | - [x] v1.0: 异步和并发限制支持,对资源的生成请求立即响应,提供查询接口返回任务实时状态 387 | - [ ] v1.1: 回调支持,异步生成请求的任务完成主动触发 HookUrl 388 | 389 | ___ 390 | 391 | 更多视频梗期待添加中…… 392 | -------------------------------------------------------------------------------- /blog_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "html/template" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "fmt" 11 | ) 12 | 13 | func TestTplConvContent(t *testing.T) { 14 | assBuf, _ := ioutil.ReadFile("./resources/template/sorry/template.ass") 15 | outputAss, _ := os.Create("./dist/output.ass") 16 | makeAss(string(assBuf), outputAss) 17 | } 18 | 19 | func makeAss(tplContentText string, fWriter io.Writer) { 20 | sentences := []string{ 21 | "好啊", 22 | "就算你是一流程序员", 23 | "写出来的代码再完美", 24 | "我说这是 BUG 它就是 BUG", 25 | "毕竟我是用户", 26 | "你害我加班啊", 27 | "sorry 我就喜欢看程序猿加班", 28 | "以后天天找他 BUG", 29 | "天天找 天天找", 30 | } 31 | data := map[string][]string{ 32 | "sentences": sentences, 33 | } 34 | tpl := template.New("subTitle") 35 | tpl, _ = tpl.Parse(tplContentText) 36 | tpl.Execute(fWriter, data) 37 | } 38 | 39 | func TestMakeVideo(t *testing.T) { 40 | makeVideo() 41 | } 42 | 43 | func makeVideo() { 44 | cmd := exec.Command("ffmpeg", "-i", "./resources/template/sorry/template.mp4", 45 | "-vf", fmt.Sprintf("ass=%s", "./dist/output.ass"), 46 | "-an", 47 | "-y", "./dist/output.mp4") 48 | cmd.Start() 49 | } -------------------------------------------------------------------------------- /concurrency_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "errors" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/go-resty/resty" 10 | ) 11 | 12 | func TestConcurrency(t *testing.T) { 13 | for i :=16; i > 0; i-- { 14 | resp, err := resty.R(). 15 | SetHeader("Content-Type", "application/json"). 16 | SetBody(`{"sentences":["` + strconv.Itoa(i) + `第一句","第二句","第三句","第四句","第五句","第六句","第七句","第八句","第九句"]}`). 17 | Post("http://localhost:8080/task/generate/sorry") 18 | if err != nil { 19 | t.Error(err) 20 | } 21 | if resp.StatusCode() != 200 { 22 | t.Error(errors.New(fmt.Sprintf("http err status: %d", resp.StatusCode()))) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /core.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "crypto/md5" 6 | "fmt" 7 | ) 8 | 9 | type Subs struct { 10 | subs []string 11 | } 12 | 13 | func (s *Subs) Append(sub interface{}) *Subs { 14 | switch sub.(type) { 15 | case string: 16 | s.subs = append(s.subs, sub.(string)) 17 | case []string: 18 | tmpSlice := sub.([]string) 19 | for i := 0; i < len(tmpSlice); i++ { 20 | s.subs = append(s.subs, tmpSlice[i]) 21 | } 22 | } 23 | return s 24 | } 25 | 26 | func (s *Subs) EntrySet() []string { 27 | return s.subs 28 | } 29 | 30 | func (s *Subs) Hash(prefix string) string { 31 | md5Buf := md5.Sum([]byte(prefix + strings.Join(s.EntrySet(), ","))) 32 | return fmt.Sprintf("%x", md5Buf[:]) 33 | } 34 | -------------------------------------------------------------------------------- /ffmpegw.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "io/ioutil" 7 | "html/template" 8 | "os" 9 | "errors" 10 | ) 11 | 12 | func MakeGif(tplKey string, subs Subs) (string, error) { 13 | return GenerateResource(tplKey, subs, "gif") 14 | } 15 | func MakeMp4(tplKey string, subs Subs) (string, error) { 16 | return GenerateResource(tplKey, subs, "mp4") 17 | } 18 | 19 | // GenerateResource Generate resources(gif/mp4) 20 | // ffmpeg CLI wrapper 21 | func GenerateResource(tplKey string, subs Subs, resType string) (hash string, err error) { 22 | tplPath := fmt.Sprintf("./resources/template/%s", tplKey) 23 | videoTplFile := tplPath + "/template.mp4" 24 | subTplFile := tplPath + "/template.ass" 25 | hash = subs.Hash(tplKey) 26 | subOutputFile := fmt.Sprintf("./dist/%s", hash) 27 | outputResource := "dist/" + hash + "." + resType 28 | if _, err = os.Stat(outputResource); os.IsNotExist(err) { 29 | tplText := "" 30 | if tmpBuf, err := ioutil.ReadFile(subTplFile); err != nil { 31 | return hash, err 32 | } else { 33 | tplText = string(tmpBuf) 34 | } 35 | tpl := template.New("subTitle") 36 | if tpl, err = tpl.Parse(tplText); err != nil { 37 | return 38 | } else { 39 | if f, err := os.Create(subOutputFile); err != nil { 40 | return hash, err 41 | } else { 42 | data := map[string][]string{ 43 | "sentences": subs.EntrySet(), 44 | } 45 | if err = tpl.Execute(f, data); err != nil { 46 | return hash, err 47 | } 48 | } 49 | } 50 | var cmd = &exec.Cmd{} 51 | switch resType { 52 | case "gif": 53 | cmd = exec.Command("ffmpeg", "-i", videoTplFile, 54 | "-vf", fmt.Sprintf("ass=%s,scale=300:-1", subOutputFile), 55 | "-r", "8", 56 | "-y", outputResource) 57 | case "mp4": 58 | cmd = exec.Command("ffmpeg", "-i", videoTplFile, 59 | "-vf", fmt.Sprintf("ass=%s", subOutputFile), 60 | "-an", 61 | "-y", outputResource) 62 | default: 63 | return "", errors.New("Unknown resType: " + resType) 64 | } 65 | if _, err := cmd.CombinedOutput(); err != nil { 66 | return hash, err 67 | } 68 | } else { 69 | return 70 | } 71 | return 72 | } 73 | -------------------------------------------------------------------------------- /ffmpegw_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGeneratorResource(t *testing.T) { 8 | tplKey := "dagong" 9 | subs := Subs{} 10 | subs.Append("没有钱啊肯定要做啊"). 11 | Append("不做的话又没有钱用"). 12 | Append("那你不会打工啊"). 13 | Append("有手有脚的"). 14 | Append("打工是不可能打工的"). 15 | Append("这辈子不可能打工的") 16 | 17 | if _, err := MakeMp4(tplKey, subs); err != nil { 18 | t.Error(err) 19 | } 20 | if _, err := MakeGif(tplKey, subs); err != nil { 21 | t.Error(err) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /github_deploy_key.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hentioe/sorry-generator/e9bc155ab82835a73817dc967afd25b2f1fa688e/github_deploy_key.enc -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | func init() { 15 | if parentPath, err := filepath.Abs(filepath.Dir(os.Args[0])); err != nil { 16 | log.Fatal(err) 17 | } else { 18 | distDir := parentPath + "/dist" 19 | resourcesDir := parentPath + "/resources" 20 | tmpDir := parentPath + "/tmp" 21 | if err := IfNotExistMkAllMir(0774, distDir, resourcesDir, tmpDir); err != nil { 22 | log.Fatal(err) 23 | } 24 | } 25 | } 26 | 27 | var bind = flag.String("bind", ":8080", "bind address and port") 28 | var installRes = flag.String("i", "", "install resources for a zip file") 29 | var mode = flag.String("mode", "test", "running mode, e.g. debug/test/release") 30 | var cl = flag.Int("cl", runtime.NumCPU(), "concurrency limits") 31 | 32 | func main() { 33 | flag.Parse() 34 | if *installRes != "" { 35 | if _, err := InstallZip(*installRes, "./resources"); err != nil { 36 | fmt.Printf("install template resources failed, %s\n", err) 37 | os.Exit(1) 38 | } 39 | fmt.Println("install template resources succcess.") 40 | os.Exit(0) 41 | } 42 | gin.SetMode(*mode) 43 | server := Server{router: gin.Default(), bind: *bind} 44 | for i := 0; i < *cl; i++ { 45 | go asyncMakeAction() 46 | } 47 | log.Fatal(server.Run()) 48 | } 49 | -------------------------------------------------------------------------------- /res_installer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "path/filepath" 5 | "os" 6 | "io" 7 | "archive/zip" 8 | "strings" 9 | "fmt" 10 | "bytes" 11 | "regexp" 12 | "io/ioutil" 13 | ) 14 | 15 | func InstallZip(src string, dest string) ([]string, error) { 16 | 17 | fileNames := []string{} 18 | 19 | if exist, err := IsAllExist(src, dest); !exist { 20 | return fileNames, err 21 | } 22 | 23 | r, err := zip.OpenReader(src) 24 | if err != nil { 25 | return fileNames, err 26 | } 27 | defer r.Close() 28 | 29 | for _, f := range r.File { 30 | 31 | rc, err := f.Open() 32 | if err != nil { 33 | return fileNames, err 34 | } 35 | 36 | // Store filename/path for returning and using later on 37 | fPath := filepath.Join(dest, f.Name) 38 | if exist, _ := IsExist(fPath); exist { 39 | continue 40 | } 41 | // 构建模板 42 | if err := makeTpl(fPath, &rc); err != nil { 43 | return fileNames, err 44 | } 45 | 46 | fileNames = append(fileNames, fPath) 47 | 48 | if f.FileInfo().IsDir() { 49 | 50 | // Make Folder 51 | os.MkdirAll(fPath, os.ModePerm) 52 | 53 | } else { 54 | 55 | // Make File 56 | if err = os.MkdirAll(filepath.Dir(fPath), os.ModePerm); err != nil { 57 | return fileNames, err 58 | } 59 | 60 | outFile, err := os.OpenFile(fPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 61 | if err != nil { 62 | return fileNames, err 63 | } 64 | 65 | _, err = io.Copy(outFile, rc) 66 | 67 | // Close the file without defer to close before next iteration of loop 68 | outFile.Close() 69 | 70 | if err != nil { 71 | return fileNames, err 72 | } 73 | 74 | } 75 | rc.Close() 76 | } 77 | return fileNames, nil 78 | } 79 | 80 | func makeTpl(fPath string, rc *io.ReadCloser) error { 81 | 82 | if strings.HasSuffix(fPath, ".ass") { 83 | // 重写 .ass 内容 84 | assContentBuf := new(bytes.Buffer) 85 | assContentBuf.ReadFrom(*rc) 86 | assContent := assContentBuf.String() 87 | // Dialogue: 0,0:00:01.18,0:00:01.56,sorry,,0,0,0,,{{ index .sentences 0 }} 88 | i := 0 89 | var newLines []string 90 | var sentences []string 91 | var name string 92 | for _, line := range strings.Split(assContent, "\n") { 93 | var newLine string 94 | // 匹配字幕内容 95 | reg := regexp.MustCompile("^Dialogue.+sorry,,0,0,0,,(.+)$") 96 | if results := reg.FindStringSubmatch(line); len(results) > 0 { 97 | sentence := results[1] 98 | sentenceTpl := fmt.Sprintf("{{ index .sentences %d }}", i) 99 | i++ 100 | newLine = strings.Replace(line, sentence, sentenceTpl, -1) 101 | sentences = append(sentences, sentence) 102 | } else { 103 | newLine = line 104 | } 105 | newLines = append(newLines, newLine) 106 | // 截取 title 属性 107 | reg = regexp.MustCompile("^Title:\\s*(.+)$") 108 | if results := reg.FindStringSubmatch(line); len(results) > 0 { 109 | name = results[1] 110 | } 111 | 112 | } 113 | assTplContent := strings.Join(newLines, "\n") 114 | *rc = ioutil.NopCloser(bytes.NewBuffer([]byte(assTplContent))) 115 | if pPath, err := filepath.Abs(filepath.Dir(fPath)); err != nil { 116 | return err 117 | } else { 118 | // 创建 sentences 文件 119 | if err := ioutil.WriteFile(pPath+"/sentences", []byte(strings.Join(sentences, "\n")), 0755); err != nil { 120 | return err 121 | } 122 | // 创建 name 文件 123 | if name != "" { 124 | if err := ioutil.WriteFile(pPath+"/name", []byte(name), 0755); err != nil { 125 | return err 126 | } 127 | } 128 | } 129 | } 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /res_installer_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestUnzip(t *testing.T) { 6 | if _, err := InstallZip("./assets/res.zip", "./resources"); err != nil { 7 | t.Error(err) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /runner.conf: -------------------------------------------------------------------------------- 1 | root: . 2 | tmp_path: ./tmp 3 | build_name: runner-build 4 | running_args: -mode debug 5 | build_log: runner-build-errors.log 6 | valid_ext: .go, .tpl, .tmpl, .html 7 | no_rebuild_ext: .tpl, .tmpl, .html 8 | ignored: assets, tmp, dist 9 | build_delay: 600 10 | colors: 1 11 | log_color_main: cyan 12 | log_color_build: yellow 13 | log_color_runner: green 14 | log_color_watcher: magenta 15 | log_color_app: -------------------------------------------------------------------------------- /scaner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "regexp" 7 | "errors" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | NotFoundTemplateProperties = errors.New("not found any .sentences template") 13 | ) 14 | 15 | type ResInfo struct { 16 | TplKey string `json:"tpl_key"` 17 | Name string `json:"name"` 18 | Sentences []string `json:"sentences"` 19 | SentencesCount int `json:"sentences_count"` 20 | } 21 | 22 | const baseDir = "./resources/template" 23 | 24 | func ScanAllTemplate() (rs []ResInfo, err error) { 25 | rs = []ResInfo{} 26 | if files, err := ioutil.ReadDir(baseDir); err != nil { 27 | return rs, err 28 | } else { 29 | for _, f := range files { 30 | if r, err := ScanTemplate(f.Name()); err == nil { 31 | rs = append(rs, r) 32 | } 33 | } 34 | } 35 | return 36 | } 37 | 38 | func ScanTemplate(tplKey string) (ri ResInfo, err error) { 39 | ri = ResInfo{TplKey: tplKey} 40 | basePath := fmt.Sprintf("%s/%s", baseDir, tplKey) 41 | assFilePath := fmt.Sprintf("%s/%s", basePath, "template.ass") 42 | videoFilePath := fmt.Sprintf("%s/%s", basePath, "template.mp4") 43 | if exist, err := IsAllExist(basePath, assFilePath, videoFilePath); !exist { 44 | return ri, err 45 | } 46 | // 读取模板内容 47 | // 扫描句子模板数量 48 | if tmpBuf, err := ioutil.ReadFile(assFilePath); err != nil { 49 | return ri, err 50 | } else { 51 | tmpAssContent := string(tmpBuf) 52 | if reg, err := regexp.Compile("{{\\s*index\\s*\\.sentences\\s*[0-9]+\\s*}}"); err != nil { 53 | return ri, err 54 | } else { 55 | results := reg.FindAllString(tmpAssContent, -1) 56 | if results == nil { 57 | return ri, NotFoundTemplateProperties 58 | } else { 59 | ri.SentencesCount = len(results) 60 | } 61 | } 62 | } 63 | 64 | // 读取 sentences 内容 65 | // 扫描每一条预设句子 66 | sentencesFilePath := fmt.Sprintf("%s/%s", basePath, "sentences") 67 | if exist, _ := IsExist(sentencesFilePath); !exist { 68 | ri.Sentences = []string{} 69 | } else { 70 | if tmpBuf, err := ioutil.ReadFile(sentencesFilePath); err != nil { 71 | return ri, err 72 | } else { 73 | tmpSentencesContent := string(tmpBuf) 74 | results := strings.Split(tmpSentencesContent, "\n") 75 | if results == nil { 76 | results = []string{} 77 | } 78 | ri.Sentences = results[:ri.SentencesCount] 79 | } 80 | } 81 | 82 | // 读取 name 属性 83 | nameFilePath := fmt.Sprintf("%s/%s", basePath, "name") 84 | if exist, _ := IsExist(nameFilePath); !exist { 85 | ri.Name = ri.TplKey 86 | } else { 87 | if tmpBuf, err := ioutil.ReadFile(nameFilePath); err != nil { 88 | return ri, err 89 | } else { 90 | ri.Name = string(tmpBuf) 91 | } 92 | } 93 | 94 | return ri, nil 95 | } 96 | -------------------------------------------------------------------------------- /scriptw: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | BIN_NAME=sorry-gen 4 | 5 | if [ "$#" -ne 1 ] 6 | then 7 | echo -e " Please add a argument" 8 | echo -e " Usage: ./scriptw [arg]\n" 9 | echo -e "\t pack\t packing app" 10 | echo -e "\t clean\t clean dist and build dir" 11 | fi 12 | if [ "$1" == 'pack' ] 13 | then 14 | if [ -e ./build ];then "$0" clean ;fi 15 | mkdir build 16 | go build -ldflags "-s -w" -o "$BIN_NAME" 17 | tar -zcvf sorry-gen.tar.gz "$BIN_NAME" 18 | mv sorry-gen.tar.gz build/ 19 | rm "$BIN_NAME" 20 | fi 21 | if [ "$1" == 'clean' ] 22 | then 23 | rm -rf ./build ./dist 24 | fi -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type Server struct { 10 | bind string 11 | router *gin.Engine 12 | } 13 | 14 | // Run 启动 web 服务 15 | func (s *Server) Run() error { 16 | router := s.router 17 | 18 | router.GET("/", func(c *gin.Context) { 19 | if res, err := ScanAllTemplate(); err != nil { 20 | c.JSON(http.StatusInternalServerError, err) 21 | } else { 22 | c.JSON(http.StatusOK, map[string]interface{}{ 23 | "res_count": len(res), 24 | "res": res, 25 | }) 26 | } 27 | }) 28 | router.GET("/info/:tpl_key", func(c *gin.Context) { 29 | tplKey := c.Param("tpl_key") 30 | if r, err := ScanTemplate(tplKey); err != nil { 31 | c.JSON(http.StatusInternalServerError, err) 32 | } else { 33 | c.JSON(http.StatusOK, r) 34 | } 35 | }) 36 | router.POST("/generate/:tpl_key/:res_type", func(c *gin.Context) { 37 | tplKey := c.Param("tpl_key") 38 | resType := c.Param("res_type") 39 | body := map[string][]string{} 40 | if err := c.ShouldBindJSON(&body); err != nil { 41 | c.JSON(http.StatusInternalServerError, err) 42 | } else { 43 | subs := Subs{} 44 | subs.Append(body["sentences"]) 45 | if hash, err := GenerateResource(tplKey, subs, resType); err != nil { 46 | c.JSON(http.StatusInternalServerError, err) 47 | } else { 48 | c.JSON(http.StatusOK, map[string]string{ 49 | "hash": hash, 50 | }) 51 | } 52 | } 53 | }) 54 | router.POST("/task/generate/:tpl_key", func(c *gin.Context) { 55 | tplKey := c.Param("tpl_key") 56 | body := map[string][]string{} 57 | if err := c.ShouldBindJSON(&body); err != nil { 58 | c.JSON(http.StatusInternalServerError, err) 59 | } else { 60 | subs := Subs{} 61 | subs.Append(body["sentences"]) 62 | hash := addMakeTask(Task{RunnableList: []makeFunc{MakeMp4, MakeGif}, TplKey: tplKey, Subs: subs}) 63 | c.JSON(http.StatusOK, map[string]interface{}{ 64 | "hash": hash, 65 | "state": taskState[hash], 66 | }) 67 | } 68 | }) 69 | router.GET("/task/generate/:hash", func(c *gin.Context) { 70 | hash := c.Param("hash") 71 | c.JSON(http.StatusOK, map[string]interface{}{ 72 | "hash": hash, 73 | "state": loadTaskState(hash), 74 | }) 75 | }) 76 | router.POST("/upload/res", func(c *gin.Context) { 77 | if file, err := c.FormFile("file"); err != nil { 78 | c.JSON(http.StatusInternalServerError, err) 79 | } else { 80 | if err := c.SaveUploadedFile(file, "./tmp"+"/"+file.Filename); err != nil { 81 | c.JSON(http.StatusInternalServerError, err) 82 | } else { 83 | if files, err := InstallZip("./tmp/"+file.Filename, "./resources"); err != nil { 84 | c.JSON(http.StatusInternalServerError, err) 85 | } else { 86 | c.JSON(http.StatusOK, map[string]interface{}{ 87 | "make_files": files, 88 | }) 89 | } 90 | } 91 | } 92 | }) 93 | 94 | return router.Run(s.bind) 95 | } 96 | -------------------------------------------------------------------------------- /tasks.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | const ( 8 | // StateWaiting 等待状态(添加后默认) 9 | StateWaiting = "waiting" 10 | // StateCompleted 完成状态 11 | StateCompleted = "completed" 12 | // StateError 失败状态 13 | StateError = "failed" 14 | // StateNone 空状态(没有构建任务) 15 | StateNone = "none" 16 | ) 17 | 18 | // 保护任务状态的互斥量 19 | var taskStateMutex sync.Mutex 20 | 21 | // 执行任务的缓冲通道 22 | var taskChan = make(chan Task, *cl) 23 | 24 | // 储存任务状态的 map 25 | var taskState = make(map[string]string) 26 | 27 | // Task 添加到队列的任务结构体 28 | type Task struct { 29 | TplKey string 30 | Subs Subs 31 | RunnableList []makeFunc 32 | } 33 | 34 | // 35 | type makeFunc func(string, Subs) (string, error) 36 | 37 | // addMakeTask 添加一个生成任务 38 | func addMakeTask(task Task) string { 39 | go func() { 40 | taskChan <- task 41 | }() 42 | hash := task.Subs.Hash(task.TplKey) 43 | updateTaskState(hash, StateWaiting) 44 | return hash 45 | } 46 | 47 | // updateTaskState 更新任务状态 48 | // 状态更新操作加锁 49 | func updateTaskState(hash, state string) { 50 | taskStateMutex.Lock() 51 | defer taskStateMutex.Unlock() 52 | 53 | taskState[hash] = state 54 | } 55 | 56 | // loadTaskState 读取任务状态 57 | // 状态更新操作加锁 58 | func loadTaskState(hash string) (state string) { 59 | taskStateMutex.Lock() 60 | defer taskStateMutex.Unlock() 61 | 62 | resultState, exists := taskState[hash] 63 | if !exists { 64 | state = StateNone 65 | } else { 66 | state = resultState 67 | } 68 | return 69 | } 70 | 71 | // asyncMakeAction 异步生成任务启动 72 | // goroutine 函数 73 | func asyncMakeAction() { 74 | for { 75 | task := <-taskChan 76 | var curTaskHash string 77 | var err error 78 | for _, f := range task.RunnableList { 79 | curTaskHash, err = f(task.TplKey, task.Subs) 80 | } 81 | if err != nil { 82 | updateTaskState(curTaskHash, StateError) 83 | } else { 84 | updateTaskState(curTaskHash, StateCompleted) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func IsExist(path string) (bool, error) { 8 | if _, err := os.Stat(path); os.IsNotExist(err) { 9 | return false, err 10 | } else { 11 | return true, err 12 | } 13 | } 14 | 15 | func IsAllExist(paths ...string) (bool, error) { 16 | for i := 0; i < len(paths); i++ { 17 | if exist, err := IsExist(paths[i]); !exist { 18 | return false, err 19 | } 20 | } 21 | return true, nil 22 | } 23 | 24 | func IfNotExistMkdir(path string, mode int32) error { 25 | if _, err := os.Stat(path); os.IsNotExist(err) { 26 | os.Mkdir(path, os.FileMode(mode)) 27 | } else if err != nil { 28 | return err 29 | } 30 | return nil 31 | } 32 | 33 | func IfNotExistMkAllMir(mode int32, paths ...string) error { 34 | for _, path := range paths { 35 | if err := IfNotExistMkdir(path, mode); err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | --------------------------------------------------------------------------------