├── .eslintignore ├── .eslintrc.js ├── .firebaserc ├── .gitignore ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── common ├── common.ts └── test.ts ├── dev ├── main.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── frontend ├── firebaseOptions.js ├── package-lock.json ├── package.json ├── src │ ├── editor │ │ ├── background.css │ │ ├── background.ts │ │ ├── editor.css │ │ ├── editor.html │ │ ├── editor.tsx │ │ ├── editorComponent.tsx │ │ ├── gizmo.ts │ │ ├── image.ts │ │ ├── manager.ts │ │ ├── modal.tsx │ │ ├── modalProgress.tsx │ │ ├── motionTracker.ts │ │ ├── renderer.ts │ │ ├── spinner.css │ │ ├── spinner.html │ │ ├── spinner.ts │ │ ├── stickerSearch.tsx │ │ ├── timeline.ts │ │ ├── utility.ts │ │ ├── videoEncoder.ts │ │ ├── videoEncoderBrowser.ts │ │ ├── videoEncoderGif.ts │ │ ├── videoEncoderH264MP4.ts │ │ ├── videoEncoderWebm.ts │ │ ├── videoPlayer.css │ │ ├── videoPlayer.ts │ │ └── videoSeeker.ts │ ├── index.htm │ ├── index.tsx │ ├── page │ │ ├── animationVideo.tsx │ │ ├── fonts.css │ │ ├── hashScroll.tsx │ │ ├── likeButton.tsx │ │ ├── login.tsx │ │ ├── post.tsx │ │ ├── profile.tsx │ │ ├── progress.tsx │ │ ├── shareButton.tsx │ │ ├── style.tsx │ │ ├── submitButton.tsx │ │ ├── thread.tsx │ │ ├── trashButton.tsx │ │ └── userAvatar.tsx │ ├── public │ │ ├── LICENSE_OFL.txt │ │ ├── arvo.ttf │ │ ├── giphy.png │ │ ├── icon.png │ │ ├── sample.mp4 │ │ └── sample.webm │ ├── shared │ │ ├── firebase.ts │ │ ├── shared.ts │ │ └── unload.tsx │ └── tracking.html ├── title.js ├── tsconfig.json └── webpack.config.js ├── functions ├── package-lock.json ├── package.json ├── src │ └── index.ts ├── tsconfig.json └── webpack.config.js ├── package-lock.json ├── package.json ├── preview.gif ├── storage.rules ├── test ├── package-lock.json ├── package.json ├── test.ts ├── tsconfig.json └── webpack.config.js ├── todo.txt └── ts-schema-loader ├── main.ts ├── package-lock.json ├── package.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | parserOptions: { 4 | project: './tsconfig.json', 5 | }, 6 | extends: [ 7 | "eslint:all", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | sourceType: "module", 14 | ecmaFeatures: { 15 | jsx: true, 16 | }, 17 | }, 18 | env: { 19 | browser: true, 20 | node: true, 21 | es6: true, 22 | }, 23 | rules: { 24 | "indent": "off", 25 | "@typescript-eslint/indent": ["error", 2], 26 | "@typescript-eslint/explicit-function-return-type": "off", 27 | "@typescript-eslint/no-explicit-any": "off", 28 | "no-extra-parens": "off", 29 | "@typescript-eslint/no-extra-parens": ["error"], 30 | "@typescript-eslint/explicit-member-accessibility": ["error"], 31 | 32 | "max-statements": ["error", 100], 33 | "max-lines-per-function": ["error", 200], 34 | "max-len": ["error", 120], 35 | "max-lines": "off", 36 | "padded-blocks": ["error", "never"], 37 | "object-property-newline": "off", 38 | "object-curly-newline": ["error", { "multiline": true, "consistent": true }], 39 | "multiline-ternary": "off", 40 | "function-call-argument-newline": ["error", "consistent"], 41 | 42 | "no-console": "off", 43 | "no-process-env": "off", 44 | 45 | "quote-props": ["error", "consistent-as-needed"], 46 | "one-var": "off", 47 | "no-ternary": "off", 48 | "no-confusing-arrow": "off", 49 | "no-await-in-loop": "off", 50 | "no-magic-numbers": "off", 51 | "no-new": "off", 52 | "require-await": "off", 53 | "class-methods-use-this": "off", 54 | "@typescript-eslint/camelcase": "off", 55 | "global-require": "off", 56 | "callback-return": "off", 57 | "no-plusplus": "off", 58 | "max-params": ["error", 6], 59 | "no-sync": "off", 60 | 61 | "default-case": "off", 62 | "no-undef": "off", 63 | "max-classes-per-file": "off", 64 | "prefer-named-capture-group": "off", 65 | "require-atomic-updates": "off", 66 | "no-bitwise": "off", 67 | "no-mixed-operators": "off", 68 | "id-length": "off", 69 | "no-continue": "off", 70 | "no-warning-comments": "off", 71 | "complexity": "off", 72 | "max-lines-per-function": "off", 73 | "implicit-arrow-linebreak": "off", 74 | "sort-keys": "off", 75 | "no-undefined": "off", 76 | "@typescript-eslint/no-non-null-assertion": "off", 77 | "react/prop-types": "off", 78 | "react/display-name": "off", 79 | "array-element-newline": "off", 80 | "default-param-last": "off", 81 | "newline-per-chained-call": "off" 82 | }, 83 | settings: { 84 | react: { 85 | version: "16.13.1", 86 | }, 87 | }, 88 | }; -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "gifygram-site" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | /public 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | firebase-debug.log* 13 | 14 | # Firebase cache 15 | .firebase/ 16 | 17 | # Firebase config 18 | 19 | # Uncomment this if you'd like others to create their own Firebase project. 20 | # For a team working on the same Firebase project(s), it is recommended to leave 21 | # it commented so all members can deploy to the same project(s) in .firebaserc. 22 | # .firebaserc 23 | 24 | # Runtime data 25 | pids 26 | *.pid 27 | *.seed 28 | *.pid.lock 29 | 30 | # Directory for instrumented libs generated by jscoverage/JSCover 31 | lib-cov 32 | 33 | # Coverage directory used by tools like istanbul 34 | coverage 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # Bower dependency directory (https://bower.io/) 43 | bower_components 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (http://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional REPL history 61 | .node_repl_history 62 | 63 | # Output of 'npm pack' 64 | *.tgz 65 | 66 | # Yarn Integrity file 67 | .yarn-integrity 68 | 69 | # dotenv environment variables file 70 | .env 71 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '11' 4 | script: 5 | - npm run buildAll -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.tabSize": 2, 4 | "eslint.validate": [ 5 | "javascript", 6 | "javascriptreact", 7 | "typescript", 8 | "typescriptreact" 9 | ], 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": true 12 | }, 13 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 TrevorSundberg 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 | # Made It For Fun 2 | A [stupid website](https://gifygram.com/) for animating images on top of videos. 3 | 4 | ![A silly animation](preview.gif) 5 | 6 | # Building & Running Locally 7 | 8 | In the root directory run: 9 | 10 | ```bash 11 | npm run installAll 12 | npm start 13 | ``` -------------------------------------------------------------------------------- /common/common.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | export const MAX_VIDEO_SIZE = 720; 3 | 4 | export const API_THREAD_LIST_ENDING = "0000000-0000-4000-8000-000000000000"; 5 | export const API_ALL_THREADS_ID = `0${API_THREAD_LIST_ENDING}`; 6 | export const API_TRENDING_THREADS_ID = `1${API_THREAD_LIST_ENDING}`; 7 | export const API_REMIXED_THREADS_ID = `2${API_THREAD_LIST_ENDING}`; 8 | 9 | export const COLLECTION_USERS = "users"; 10 | export const COLLECTION_AVATARS = "avatars"; 11 | export const COLLECTION_VIDEOS = "videos"; 12 | export const COLLECTION_ANIMATIONS = "animations"; 13 | export const COLLECTION_POSTS = "posts"; 14 | export const COLLECTION_LIKED = "liked"; 15 | export const COLLECTION_VIEWED = "viewed"; 16 | 17 | /** Mark that we're doing something only to be backwards compatable with the database */ 18 | export const oldVersion = (value: T) => value; 19 | 20 | export const makeLikedKey = (postId: string, userId: string) => `${postId}_${userId}`; 21 | 22 | export const userHasPermission = (actingUser: StoredUser | null, owningUserId: string) => 23 | actingUser 24 | ? owningUserId === actingUser.id || actingUser.role === "admin" 25 | : false; 26 | 27 | export type Empty = {}; 28 | 29 | export type PostComment = { 30 | type: "comment"; 31 | } 32 | 33 | export interface AttributedSource { 34 | originUrl: string; 35 | title: string; 36 | previewUrl: string; 37 | src: string; 38 | mimeType: string; 39 | } 40 | 41 | export type PostAnimation = { 42 | type: "animation"; 43 | attribution: AttributedSource[]; 44 | width: number; 45 | height: number; 46 | } 47 | 48 | export type PostData = PostComment | PostAnimation; 49 | 50 | export interface PostCreate { 51 | 52 | /** 53 | * @minLength 1 54 | * @maxLength 1000 55 | */ 56 | message: string; 57 | threadId: string; 58 | } 59 | 60 | export interface AnimationCreate { 61 | 62 | /** 63 | * @maxLength 1000 64 | */ 65 | message: string; 66 | 67 | // This is the video/animation we are remixing (null if we are creating a new thread). 68 | replyId: string | null; 69 | 70 | /** 71 | * @maxLength 26 72 | */ 73 | title: string; 74 | 75 | /** 76 | * MAX_VIDEO_SIZE 77 | * @minimum 1 78 | * @maximum 720 79 | * @type integer 80 | */ 81 | width: number; 82 | 83 | /** 84 | * MAX_VIDEO_SIZE 85 | * @minimum 1 86 | * @maximum 720 87 | * @type integer 88 | */ 89 | height: number; 90 | } 91 | 92 | export interface ViewedThread { 93 | threadId: string; 94 | } 95 | 96 | export type PostType = "thread" | "comment" | "remix"; 97 | 98 | export interface StoredPost { 99 | id: string; 100 | type: PostType; 101 | threadId: string; 102 | title: string | null; 103 | message: string; 104 | userdata: PostData; 105 | userId: string; 106 | replyId: string | null; 107 | dateMsSinceEpoch: number; 108 | likes: number; 109 | likesSecondsFromBirthAverage: number; 110 | trendingScore: number; 111 | views: number; 112 | } 113 | 114 | export interface ThreadPost { 115 | userdata: PostAnimation; 116 | } 117 | 118 | export type StoredThread = StoredPost & ThreadPost; 119 | 120 | export interface ClientPost extends StoredPost { 121 | username: string; 122 | avatarId: string | null; 123 | liked: boolean; 124 | canDelete: boolean; 125 | } 126 | 127 | export type ClientThread = ClientPost & ThreadPost; 128 | 129 | export interface StoredUser { 130 | id: string; 131 | avatarId: string | null; 132 | username: string; 133 | bio: string; 134 | role: "user" | "admin"; 135 | } 136 | 137 | export interface AvatarInput { 138 | avatarId: string; 139 | } 140 | 141 | export interface PostLikeInput { 142 | id: string; 143 | liked: boolean; 144 | } 145 | 146 | export interface PostLike { 147 | likes: number; 148 | } 149 | 150 | export interface PostDelete { 151 | id: string; 152 | } 153 | 154 | export interface Keyframe { 155 | clip?: string; 156 | transform?: string; 157 | } 158 | 159 | export interface Track { 160 | [time: string]: Keyframe; 161 | } 162 | 163 | export interface Tracks { 164 | [selector: string]: Track; 165 | } 166 | 167 | export interface WidgetInit { 168 | attributedSource: AttributedSource; 169 | id?: string; 170 | } 171 | 172 | export interface AnimationData { 173 | videoAttributedSource: AttributedSource; 174 | tracks: Tracks; 175 | widgets: WidgetInit[]; 176 | } 177 | 178 | export interface SpecificPost { 179 | id: string; 180 | } 181 | 182 | export interface ProfileUpdate { 183 | 184 | /** 185 | * @minLength 5 186 | * @maxLength 20 187 | * @pattern ^[a-zA-Z0-9.]+$ 188 | */ 189 | username: string; 190 | 191 | /** 192 | * @maxLength 1000 193 | */ 194 | bio: string; 195 | } 196 | 197 | export interface Feedback { 198 | title: string; 199 | } 200 | 201 | type JSONSchema7 = import("json-schema").JSONSchema7; 202 | export type SchemaValidator = ((input: any) => boolean) & {errors: any[]; schema: JSONSchema7} 203 | 204 | export class Api, OutputType> { 205 | public readonly pathname: string; 206 | 207 | public readonly validator: SchemaValidator; 208 | 209 | public readonly props: Record = {} as any; 210 | 211 | private in: InputType | undefined = undefined; 212 | 213 | private out: OutputType | undefined = undefined; 214 | 215 | public constructor (pathname: string, validator: SchemaValidator) { 216 | // eslint-disable-next-line no-void 217 | void this.in; 218 | // eslint-disable-next-line no-void 219 | void this.out; 220 | this.pathname = pathname; 221 | this.validator = validator; 222 | 223 | // This is just a shortcut to access schema properties at the root level. 224 | const {properties} = validator.schema; 225 | if (properties) { 226 | for (const key of Object.keys(properties)) { 227 | const value = properties[key]; 228 | if (typeof value === "object") { 229 | (this.props as Record)[key] = value; 230 | } 231 | } 232 | } 233 | } 234 | } 235 | 236 | export const API_POST_CREATE = new Api( 237 | "/api/post/create", 238 | require("../ts-schema-loader/dist/main.js!./common.ts?PostCreate") 239 | ); 240 | export const API_VIEWED_THREAD = new Api( 241 | "/api/thread/viewed", 242 | require("../ts-schema-loader/dist/main.js!./common.ts?ViewedThread") 243 | ); 244 | export const API_POST_LIKE = new Api( 245 | "/api/post/like", 246 | require("../ts-schema-loader/dist/main.js!./common.ts?PostLikeInput") 247 | ); 248 | export const API_POST_DELETE = new Api( 249 | "/api/post/delete", 250 | require("../ts-schema-loader/dist/main.js!./common.ts?PostDelete") 251 | ); 252 | export const API_ANIMATION_CREATE = new Api( 253 | "/api/animation/create", 254 | require("../ts-schema-loader/dist/main.js!./common.ts?AnimationCreate") 255 | ); 256 | export const API_ANIMATION_JSON = new Api( 257 | "/api/animation/json", 258 | require("../ts-schema-loader/dist/main.js!./common.ts?SpecificPost") 259 | ); 260 | export const API_ANIMATION_VIDEO = new Api( 261 | "/api/animation/video", 262 | require("../ts-schema-loader/dist/main.js!./common.ts?SpecificPost") 263 | ); 264 | export const API_PROFILE_UPDATE = new Api( 265 | "/api/profile/update", 266 | require("../ts-schema-loader/dist/main.js!./common.ts?ProfileUpdate") 267 | ); 268 | export const API_PROFILE_AVATAR = new Api( 269 | "/api/profile/avatar", 270 | require("../ts-schema-loader/dist/main.js!./common.ts?AvatarInput") 271 | ); 272 | export const API_PROFILE_AVATAR_UPDATE = new Api( 273 | "/api/profile/avatar/update", 274 | require("../ts-schema-loader/dist/main.js!./common.ts?Empty") 275 | ); 276 | export const API_FEEDBACK = new Api( 277 | "/api/feedback", 278 | require("../ts-schema-loader/dist/main.js!./common.ts?Feedback") 279 | ); 280 | export const API_HEALTH = new Api( 281 | "/api/health", 282 | require("../ts-schema-loader/dist/main.js!./common.ts?Empty") 283 | ); 284 | -------------------------------------------------------------------------------- /common/test.ts: -------------------------------------------------------------------------------- 1 | export const TEST_EMAIL = "gifygram.contact@gmail.com"; 2 | export const TEST_PASSWORD = "d7M8Utm5eU7bL7vH"; 3 | -------------------------------------------------------------------------------- /dev/main.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import readline from "readline"; 5 | 6 | (async () => { 7 | const read = readline.createInterface({ 8 | input: process.stdin, 9 | output: process.stdout 10 | }); 11 | read.on("close", () => console.log("Press Ctrl+C again to exit...")); 12 | 13 | const rootDir = path.join(__dirname, "..", ".."); 14 | await execa("npm", ["run", "buildTsSchemaLoader"], { 15 | stdio: "inherit", 16 | cwd: rootDir 17 | }); 18 | 19 | // Start the webpack dev for the frontend. 20 | execa("npm", ["run", "liveWebpackFrontend"], { 21 | stdio: "inherit", 22 | cwd: path.join(rootDir, "frontend") 23 | }); 24 | 25 | // Start the webpack dev for the functions. 26 | const functionsDir = path.join(rootDir, "functions"); 27 | execa("npm", ["run", "liveWebpackFunctions"], { 28 | stdio: "inherit", 29 | cwd: functionsDir 30 | }); 31 | 32 | // Firebase emulator will fail if dist/main.js does not exist, so write an empty file. 33 | const workerJsPath = path.join(functionsDir, "dist", "main.js"); 34 | await fs.promises.writeFile(workerJsPath, "0"); 35 | 36 | // Start the Firebase emulation. 37 | execa("npm", ["run", "liveFirebaseEmulator"], { 38 | stdio: "inherit", 39 | cwd: functionsDir 40 | }); 41 | 42 | 43 | // Start the webpack dev for the tests. 44 | const testDir = path.join(rootDir, "test"); 45 | execa("npm", ["run", "liveWebpackTest"], { 46 | stdio: "inherit", 47 | cwd: testDir 48 | }); 49 | 50 | const testJsPath = path.join(testDir, "dist", "main.js"); 51 | 52 | // Wait until webpack compiles the tests. 53 | for (;;) { 54 | if (fs.existsSync(testJsPath)) { 55 | break; 56 | } 57 | await new Promise((resolve) => setTimeout(resolve, 100)); 58 | } 59 | 60 | process.env.NODE_PATH = path.join(testDir, "node_modules"); 61 | // eslint-disable-next-line no-underscore-dangle 62 | require("module").Module._initPaths(); 63 | 64 | for (let i = 0; ; ++i) { 65 | await new Promise((resolve) => read.question("Press enter to run the test...\n", resolve)); 66 | 67 | process.env.TEST_RUN_NUMBER = String(i); 68 | // eslint-disable-next-line no-eval 69 | eval(await fs.promises.readFile(testJsPath, "utf8")); 70 | } 71 | })(); 72 | -------------------------------------------------------------------------------- /dev/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "cross-spawn": { 6 | "version": "7.0.3", 7 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", 8 | "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 9 | "requires": { 10 | "path-key": "^3.1.0", 11 | "shebang-command": "^2.0.0", 12 | "which": "^2.0.1" 13 | } 14 | }, 15 | "end-of-stream": { 16 | "version": "1.4.4", 17 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", 18 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", 19 | "requires": { 20 | "once": "^1.4.0" 21 | } 22 | }, 23 | "execa": { 24 | "version": "4.0.3", 25 | "resolved": "https://registry.npmjs.org/execa/-/execa-4.0.3.tgz", 26 | "integrity": "sha512-WFDXGHckXPWZX19t1kCsXzOpqX9LWYNqn4C+HqZlk/V0imTkzJZqf87ZBhvpHaftERYknpk0fjSylnXVlVgI0A==", 27 | "requires": { 28 | "cross-spawn": "^7.0.0", 29 | "get-stream": "^5.0.0", 30 | "human-signals": "^1.1.1", 31 | "is-stream": "^2.0.0", 32 | "merge-stream": "^2.0.0", 33 | "npm-run-path": "^4.0.0", 34 | "onetime": "^5.1.0", 35 | "signal-exit": "^3.0.2", 36 | "strip-final-newline": "^2.0.0" 37 | } 38 | }, 39 | "get-stream": { 40 | "version": "5.1.0", 41 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", 42 | "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", 43 | "requires": { 44 | "pump": "^3.0.0" 45 | } 46 | }, 47 | "human-signals": { 48 | "version": "1.1.1", 49 | "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", 50 | "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==" 51 | }, 52 | "is-stream": { 53 | "version": "2.0.0", 54 | "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", 55 | "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" 56 | }, 57 | "isexe": { 58 | "version": "2.0.0", 59 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 60 | "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" 61 | }, 62 | "merge-stream": { 63 | "version": "2.0.0", 64 | "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", 65 | "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" 66 | }, 67 | "mimic-fn": { 68 | "version": "2.1.0", 69 | "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", 70 | "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" 71 | }, 72 | "npm-run-path": { 73 | "version": "4.0.1", 74 | "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", 75 | "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", 76 | "requires": { 77 | "path-key": "^3.0.0" 78 | } 79 | }, 80 | "once": { 81 | "version": "1.4.0", 82 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 83 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 84 | "requires": { 85 | "wrappy": "1" 86 | } 87 | }, 88 | "onetime": { 89 | "version": "5.1.0", 90 | "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", 91 | "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", 92 | "requires": { 93 | "mimic-fn": "^2.1.0" 94 | } 95 | }, 96 | "path-key": { 97 | "version": "3.1.1", 98 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 99 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 100 | }, 101 | "pump": { 102 | "version": "3.0.0", 103 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", 104 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", 105 | "requires": { 106 | "end-of-stream": "^1.1.0", 107 | "once": "^1.3.1" 108 | } 109 | }, 110 | "shebang-command": { 111 | "version": "2.0.0", 112 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 113 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 114 | "requires": { 115 | "shebang-regex": "^3.0.0" 116 | } 117 | }, 118 | "shebang-regex": { 119 | "version": "3.0.0", 120 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 121 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" 122 | }, 123 | "signal-exit": { 124 | "version": "3.0.3", 125 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", 126 | "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" 127 | }, 128 | "strip-final-newline": { 129 | "version": "2.0.0", 130 | "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", 131 | "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==" 132 | }, 133 | "which": { 134 | "version": "2.0.2", 135 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 136 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 137 | "requires": { 138 | "isexe": "^2.0.0" 139 | } 140 | }, 141 | "wrappy": { 142 | "version": "1.0.2", 143 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 144 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "tsc" 5 | }, 6 | "dependencies": { 7 | "execa": "^4.0.3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "noImplicitAny": false, 5 | "module": "CommonJS", 6 | "moduleResolution": "Node", 7 | "target": "ES6", 8 | "removeComments": false, 9 | "esModuleInterop": true, 10 | "outDir": "dist" 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | "dist" 15 | ] 16 | } -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "functions": { 7 | "predeploy": [ 8 | "npm --prefix \"$RESOURCE_DIR\" run build" 9 | ], 10 | "source": "functions" 11 | }, 12 | "storage": { 13 | "rules": "storage.rules" 14 | }, 15 | "emulators": { 16 | "pubsub": { 17 | "port": 5004, 18 | "host": "0.0.0.0" 19 | }, 20 | "firestore": { 21 | "port": 5003, 22 | "host": "0.0.0.0" 23 | }, 24 | "functions": { 25 | "port": 5002, 26 | "host": "0.0.0.0" 27 | }, 28 | "ui": { 29 | "enabled": true, 30 | "port": 5001, 31 | "host": "0.0.0.0" 32 | }, 33 | "hosting": { 34 | "port": 5000, 35 | "host": "0.0.0.0" 36 | } 37 | }, 38 | "hosting": { 39 | "public": "public", 40 | "ignore": [ 41 | "firebase.json", 42 | "**/.*", 43 | "**/node_modules/**" 44 | ], 45 | "rewrites": [ 46 | { 47 | "source": "/api/**", 48 | "function": "api" 49 | }, 50 | { 51 | "source": "**", 52 | "destination": "/index.html" 53 | } 54 | ], 55 | "headers": [ 56 | { 57 | "source": "{/*,/}", 58 | "headers": [ 59 | { 60 | "key": "cache-control", 61 | "value": "max-age=0" 62 | } 63 | ] 64 | }, 65 | { 66 | "source": "/public/**", 67 | "headers": [ 68 | { 69 | "key": "cache-control", 70 | "value": "public,max-age=31536000,immutable" 71 | } 72 | ] 73 | } 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "posts", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "type", 9 | "order": "ASCENDING" 10 | }, 11 | { 12 | "fieldPath": "dateMsSinceEpoch", 13 | "order": "DESCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "posts", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "threadId", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "dateMsSinceEpoch", 27 | "order": "DESCENDING" 28 | } 29 | ] 30 | }, 31 | { 32 | "collectionGroup": "posts", 33 | "queryScope": "COLLECTION", 34 | "fields": [ 35 | { 36 | "fieldPath": "type", 37 | "order": "ASCENDING" 38 | }, 39 | { 40 | "fieldPath": "trendingScore", 41 | "order": "DESCENDING" 42 | } 43 | ] 44 | } 45 | ], 46 | "fieldOverrides": [] 47 | } 48 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /{document=**} { 5 | allow read; 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /frontend/firebaseOptions.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apiKey: "AIzaSyBv0-S-2rb8m-JKs8TwF-T3uKA_knv9fpQ", 3 | authDomain: "gifygram-site.firebaseapp.com", 4 | databaseURL: "https://gifygram-site.firebaseio.com", 5 | projectId: "gifygram-site", 6 | storageBucket: "gifygram-site.appspot.com", 7 | messagingSenderId: "780323380600", 8 | appId: "1:780323380600:web:d8b1b193b0ef86e9fafc8d", 9 | measurementId: "G-E8PQ0JL7W3" 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "webpack --mode production", 5 | "liveWebpackFrontend": "webpack-dev-server --mode development" 6 | }, 7 | "keywords": [], 8 | "dependencies": { 9 | "@fortawesome/fontawesome-free": "^5.12.1", 10 | "@material-ui/core": "^4.10.0", 11 | "@material-ui/icons": "^4.9.1", 12 | "@types/base64-js": "^1.2.5", 13 | "@types/css-font-loading-module": "0.0.4", 14 | "@types/dom-mediacapture-record": "^1.0.5", 15 | "@types/html-webpack-plugin": "^3.2.3", 16 | "@types/jquery": "^3.3.33", 17 | "@types/node": "^13.13.5", 18 | "@types/pluralize": "0.0.29", 19 | "@types/react": "^16.9.41", 20 | "@types/react-dom": "^16.9.8", 21 | "@types/react-router-dom": "^5.1.5", 22 | "@types/uuid": "^3.4.8", 23 | "base64-js": "^1.3.1", 24 | "clean-webpack-plugin": "^3.0.0", 25 | "copy-to-clipboard": "^3.3.1", 26 | "css-loader": "^3.4.2", 27 | "file-loader": "^5.1.0", 28 | "firebase": "^7.17.1", 29 | "gif-encoder": "^0.7.2", 30 | "gif-frames": "^1.0.1", 31 | "h264-mp4-encoder": "^1.0.12", 32 | "html-webpack-plugin": "^3.2.0", 33 | "jquery": "^3.4.1", 34 | "jsfeat": "0.0.8", 35 | "millify": "^3.2.1", 36 | "mini-svg-data-uri": "^1.1.3", 37 | "moveable": "^0.17.10", 38 | "pluralize": "^8.0.0", 39 | "popper.js": "^1.16.1", 40 | "raw-loader": "^4.0.0", 41 | "react": "^16.13.1", 42 | "react-dom": "^16.13.1", 43 | "react-firebaseui": "^4.1.0", 44 | "react-masonry-css": "^1.0.14", 45 | "react-router-dom": "^5.2.0", 46 | "react-share": "^4.2.0", 47 | "resize-observer-polyfill": "^1.5.1", 48 | "scenejs": "^1.1.5", 49 | "style-loader": "^1.1.3", 50 | "text-to-svg": "^3.1.5", 51 | "timeago.js": "^4.0.2", 52 | "ts-loader": "^6.2.1", 53 | "typescript": "^3.9.3", 54 | "url-loader": "^3.0.0", 55 | "uuid": "^3.4.0", 56 | "webpack": "^4.44.1", 57 | "webpack-cli": "^3.3.12", 58 | "webpack-dev-server": "^3.11.0", 59 | "whammy": "0.0.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/editor/background.css: -------------------------------------------------------------------------------- 1 | .background { 2 | position: fixed; 3 | top: 0px; 4 | left: 0px; 5 | width: 100%; 6 | height: 100%; 7 | } -------------------------------------------------------------------------------- /frontend/src/editor/background.ts: -------------------------------------------------------------------------------- 1 | import "./background.css"; 2 | import {FRAME_TIME} from "./utility"; 3 | 4 | export class Background { 5 | public readonly canvas: HTMLCanvasElement; 6 | 7 | private interval: any; 8 | 9 | public constructor (parent: HTMLElement, video: HTMLVideoElement) { 10 | const canvas = document.createElement("canvas"); 11 | canvas.width = 256; 12 | canvas.height = 256; 13 | canvas.className = "background"; 14 | canvas.tabIndex = 1; 15 | this.canvas = canvas; 16 | 17 | const context = canvas.getContext("2d"); 18 | const drawVideo = () => { 19 | context.filter = "blur(10px)"; 20 | context.drawImage(video, 0, 0, canvas.width, canvas.height); 21 | context.filter = "opacity(30%)"; 22 | context.fillStyle = "#888"; 23 | context.fillRect(0, 0, canvas.width, canvas.height); 24 | }; 25 | drawVideo(); 26 | parent.prepend(canvas); 27 | 28 | window.addEventListener("resize", drawVideo); 29 | video.addEventListener("seeked", drawVideo); 30 | this.interval = setInterval(drawVideo, FRAME_TIME * 1000 * 2); 31 | } 32 | 33 | public destroy () { 34 | clearInterval(this.interval); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/editor/editor.css: -------------------------------------------------------------------------------- 1 | .editor { 2 | overflow: hidden; 3 | touch-action: none; 4 | } 5 | 6 | * { 7 | outline: none; 8 | } 9 | 10 | .widget { 11 | position: absolute; 12 | top: 0px; 13 | left: 0px; 14 | user-select: none; 15 | color: black; 16 | font: 4em Arial; 17 | outline: 1px solid transparent; 18 | } 19 | 20 | #container { 21 | position: absolute; 22 | background-color: transparent; 23 | top: 0px; 24 | left: 0px; 25 | transform-origin: top left; 26 | overflow: hidden; 27 | background-color: #333; 28 | } 29 | 30 | .fill { 31 | position: absolute; 32 | width: 100%; 33 | height: 100%; 34 | z-index: 1; 35 | } 36 | 37 | #buttons { 38 | position: absolute; 39 | top: 0px; 40 | right: 0px; 41 | /* Fix a bug in Chrome with the video drawing over sometimes */ 42 | transform: translateZ(0); 43 | z-index: 2; 44 | } 45 | 46 | .button { 47 | display: block; 48 | font-size: 8.5vh; 49 | color: white; 50 | text-align: center; 51 | text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.25); 52 | filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.25)); 53 | } 54 | 55 | .button:hover { 56 | color: black; 57 | } 58 | 59 | .button:active { 60 | color: gray; 61 | } 62 | 63 | .moveable-control-box { 64 | /* This is lower than the material UI modals */ 65 | z-index: 1000 !important; 66 | } -------------------------------------------------------------------------------- /frontend/src/editor/editor.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 |
-------------------------------------------------------------------------------- /frontend/src/editor/editor.tsx: -------------------------------------------------------------------------------- 1 | import "@fortawesome/fontawesome-free/css/fontawesome.css"; 2 | import "@fortawesome/fontawesome-free/css/solid.css"; 3 | import "@fortawesome/fontawesome-free/css/brands.css"; 4 | import "./editor.css"; 5 | import { 6 | API_ANIMATION_CREATE, 7 | API_ANIMATION_JSON 8 | } from "../../../common/common"; 9 | import { 10 | Auth, 11 | Deferred, 12 | EVENT_MENU_OPEN, 13 | NeverAsync, 14 | abortableJsonFetch, 15 | isDevEnvironment, 16 | makeLocalUrl 17 | } from "../shared/shared"; 18 | import {RenderFrameEvent, Renderer} from "./renderer"; 19 | import $ from "jquery"; 20 | import {Background} from "./background"; 21 | import {Manager} from "./manager"; 22 | import {Modal} from "./modal"; 23 | import {ModalProgress} from "./modalProgress"; 24 | import React from "react"; 25 | import {StickerSearch} from "./stickerSearch"; 26 | import TextField from "@material-ui/core/TextField"; 27 | import TextToSVG from "text-to-svg"; 28 | import {Timeline} from "./timeline"; 29 | import {Utility} from "./utility"; 30 | import {VideoEncoder} from "./videoEncoder"; 31 | import {VideoEncoderH264MP4} from "./videoEncoderH264MP4"; 32 | import {VideoPlayer} from "./videoPlayer"; 33 | import {setHasUnsavedChanges} from "../shared/unload"; 34 | import svgToMiniDataURI from "mini-svg-data-uri"; 35 | 36 | export class Editor { 37 | public root: JQuery; 38 | 39 | private background: Background; 40 | 41 | private manager: Manager; 42 | 43 | public constructor (parent: HTMLElement, history: import("history").History, remixId?: string) { 44 | document.documentElement.style.overflow = "hidden"; 45 | this.root = $(require("./editor.html").default).appendTo(parent); 46 | 47 | const getElement = (name: string) => parent.querySelector(`#${name}`); 48 | 49 | const videoParent = getElement("container") as HTMLDivElement; 50 | const widgetContainer = getElement("widgets") as HTMLDivElement; 51 | const player = new VideoPlayer(videoParent, parent); 52 | const timeline = new Timeline(); 53 | const canvas = getElement("canvas") as HTMLCanvasElement; 54 | const renderer = new Renderer(canvas, widgetContainer, player, timeline); 55 | const background = new Background(parent, player.video); 56 | this.background = background; 57 | const manager = new Manager(background, videoParent, widgetContainer, player, timeline, renderer); 58 | this.manager = manager; 59 | 60 | (async () => { 61 | manager.spinner.show(); 62 | if (remixId) { 63 | const animation = await abortableJsonFetch(API_ANIMATION_JSON, Auth.Optional, {id: remixId}); 64 | await manager.load(animation); 65 | } else { 66 | await player.setAttributedSrc({ 67 | originUrl: "", 68 | title: "", 69 | previewUrl: "", 70 | mimeType: "video/mp4", 71 | src: isDevEnvironment() 72 | ? require("../public/sample.webm").default as string 73 | : require("../public/sample.mp4").default as string 74 | }); 75 | } 76 | manager.spinner.hide(); 77 | })(); 78 | 79 | getElement("menu").addEventListener( 80 | "click", 81 | () => window.dispatchEvent(new Event(EVENT_MENU_OPEN)) 82 | ); 83 | 84 | getElement("sticker").addEventListener("click", async () => { 85 | const attributedSource = await StickerSearch.searchForStickerUrl("stickers"); 86 | if (attributedSource) { 87 | await manager.addWidget({attributedSource}); 88 | } 89 | }); 90 | 91 | const fontPromise = new Promise((resolve, reject) => { 92 | const src = require("../public/arvo.ttf").default as string; 93 | TextToSVG.load(src, (err, textToSVG) => { 94 | if (err) { 95 | reject(err); 96 | return; 97 | } 98 | resolve(textToSVG); 99 | }); 100 | }); 101 | 102 | getElement("text").addEventListener("click", async () => { 103 | const modal = new Modal(); 104 | let text = ""; 105 | const button = await modal.open({ 106 | buttons: [{dismiss: true, name: "OK", submitOnEnter: true}], 107 | render: () => { 108 | text = e.target.value; 109 | }}/>, 110 | dismissable: true, 111 | title: "Text" 112 | }); 113 | if (button && text) { 114 | const textToSVG = await fontPromise; 115 | const svgText = textToSVG.getSVG(text, { 116 | anchor: "left top", 117 | attributes: { 118 | fill: "white", 119 | stroke: "black" 120 | } 121 | }); 122 | const svg = $(svgText); 123 | const src = svgToMiniDataURI(svg.get(0).outerHTML) as string; 124 | await manager.addWidget({ 125 | attributedSource: { 126 | originUrl: "", 127 | title: "", 128 | previewUrl: "", 129 | mimeType: "image/svg+xml", 130 | src 131 | } 132 | }); 133 | } 134 | }); 135 | 136 | getElement("video").addEventListener("click", async () => { 137 | const attributedSource = await StickerSearch.searchForStickerUrl("gifs"); 138 | if (attributedSource) { 139 | manager.spinner.show(); 140 | await player.setAttributedSrc(attributedSource); 141 | manager.spinner.hide(); 142 | } 143 | }); 144 | 145 | const render = async () => { 146 | const modal = new ModalProgress(); 147 | const videoEncoder: VideoEncoder = new VideoEncoderH264MP4(); 148 | modal.open({ 149 | buttons: [ 150 | { 151 | callback: async () => { 152 | await renderer.stop(); 153 | await videoEncoder.stop(); 154 | }, 155 | name: "Cancel" 156 | } 157 | ], 158 | title: "Rendering & Encoding" 159 | }); 160 | await videoEncoder.initialize( 161 | renderer.resizeCanvas, 162 | renderer.resizeContext, 163 | player, 164 | (progress) => modal.setProgress(progress, "Encoding") 165 | ); 166 | manager.updateExternally = true; 167 | renderer.onRenderFrame = async (event: RenderFrameEvent) => { 168 | await videoEncoder.processFrame(); 169 | modal.setProgress(event.progress, "Rendering"); 170 | }; 171 | const videoBlob = await (async () => { 172 | if (await renderer.render()) { 173 | return videoEncoder.getOutputVideo(); 174 | } 175 | return null; 176 | })(); 177 | modal.hide(); 178 | renderer.onRenderFrame = null; 179 | manager.updateExternally = false; 180 | if (videoBlob) { 181 | return { 182 | videoBlob, 183 | width: renderer.resizeCanvas.width, 184 | height: renderer.resizeCanvas.height 185 | }; 186 | } 187 | return null; 188 | }; 189 | 190 | const makeLengthBuffer = (size: number) => { 191 | const view = new DataView(new ArrayBuffer(4)); 192 | view.setUint32(0, size, true); 193 | return view.buffer; 194 | }; 195 | 196 | const makePost = async (title: string, message: string) => { 197 | // Since we use 'updateExternally', any widget won't be updated, so just deselect for now. 198 | this.manager.selectWidget(null); 199 | 200 | const result = await render(); 201 | if (result) { 202 | const jsonBuffer = new TextEncoder().encode(JSON.stringify(manager.save())); 203 | const videoBuffer = await result.videoBlob.arrayBuffer(); 204 | 205 | const blob = new Blob([ 206 | makeLengthBuffer(jsonBuffer.byteLength), 207 | jsonBuffer, 208 | makeLengthBuffer(videoBuffer.byteLength), 209 | videoBuffer 210 | ]); 211 | const post = await abortableJsonFetch( 212 | API_ANIMATION_CREATE, 213 | Auth.Required, 214 | { 215 | title, 216 | message, 217 | width: result.width, 218 | height: result.height, 219 | replyId: remixId 220 | }, 221 | blob 222 | ); 223 | 224 | setHasUnsavedChanges(false); 225 | 226 | // If the user goes back to the editor in history, they'll be editing a remix of their post. 227 | history.replace(makeLocalUrl("/editor", {remixId: post.id})); 228 | if (post.id === post.threadId) { 229 | history.push(makeLocalUrl("/thread", {threadId: post.threadId})); 230 | } else { 231 | history.push(makeLocalUrl("/thread", {threadId: post.threadId}, post.id)); 232 | } 233 | } 234 | }; 235 | 236 | getElement("post").addEventListener("click", (): NeverAsync => { 237 | let title = ""; 238 | let message = ""; 239 | const modal = new Modal(); 240 | modal.open({ 241 | buttons: [ 242 | { 243 | callback: () => makePost(title, message), 244 | dismiss: true, 245 | submitOnEnter: true, 246 | name: "Post" 247 | } 248 | ], 249 | render: () =>
250 |
251 | { 258 | title = e.target.value; 259 | }}/> 260 |
261 |
262 | { 268 | message = e.target.value; 269 | }}/> 270 |
271 |
, 272 | dismissable: true, 273 | title: "Post" 274 | }); 275 | }); 276 | 277 | getElement("motion").addEventListener("click", async () => { 278 | const {selection} = manager; 279 | if (!selection) { 280 | await Modal.messageBox("Motion Tracking", "You must have something selected to perform motion tracking"); 281 | return; 282 | } 283 | const motionTrackerPromise = new Deferred(); 284 | const modal = new ModalProgress(); 285 | modal.open({ 286 | buttons: [ 287 | { 288 | callback: async () => { 289 | await (await motionTrackerPromise).stop(); 290 | modal.hide(); 291 | }, 292 | name: "Stop" 293 | } 294 | ], 295 | title: "Tracking" 296 | }); 297 | 298 | const {MotionTracker} = await import("./motionTracker"); 299 | const motionTracker = new MotionTracker(player); 300 | motionTrackerPromise.resolve(motionTracker); 301 | 302 | const transform = Utility.getTransform(selection.widget.element); 303 | motionTracker.addPoint(transform.translate[0], transform.translate[1]); 304 | 305 | motionTracker.onMotionFrame = async (event: import("./motionTracker").MotionTrackerEvent) => { 306 | modal.setProgress(event.progress, ""); 307 | if (event.found) { 308 | transform.translate[0] = event.x; 309 | transform.translate[1] = event.y; 310 | selection.setTransform(transform); 311 | selection.emitKeyframe(); 312 | } 313 | }; 314 | await motionTracker.track(); 315 | motionTracker.onMotionFrame = null; 316 | modal.hide(); 317 | }); 318 | 319 | getElement("visibility").addEventListener("click", async () => { 320 | if (!this.manager.selection) { 321 | await Modal.messageBox("Toggle Visibility", "You must have something selected to toggle visibility"); 322 | return; 323 | } 324 | const {element} = this.manager.selection.widget; 325 | manager.toggleVisibility(element); 326 | }); 327 | 328 | getElement("delete").addEventListener("click", async () => { 329 | manager.attemptDeleteSelection(); 330 | }); 331 | 332 | getElement("clear").addEventListener("click", async () => { 333 | const {selection} = manager; 334 | if (!selection) { 335 | await Modal.messageBox("Clear Keyframes", "You must have something selected to delete its key frames"); 336 | return; 337 | } 338 | const range = player.getSelectionRangeInOrder(); 339 | if (range[0] === range[1]) { 340 | await Modal.messageBox( 341 | "Clear Keyframes", 342 | "No keyframes were selected. Click and drag on the timeline to create a blue keyframe selection." 343 | ); 344 | return; 345 | } 346 | if (!manager.deleteKeyframesInRange(`#${selection.widget.init.id}`, range)) { 347 | await Modal.messageBox("Clear Keyframes", "No keyframes were deleted"); 348 | return; 349 | } 350 | manager.updateChanges(); 351 | setHasUnsavedChanges(true); 352 | }); 353 | } 354 | 355 | public destroy () { 356 | this.background.destroy(); 357 | this.manager.destroy(); 358 | this.root.remove(); 359 | document.documentElement.style.overflow = null; 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /frontend/src/editor/editorComponent.tsx: -------------------------------------------------------------------------------- 1 | import DeleteIcon from "@material-ui/icons/Delete"; 2 | import {Editor} from "./editor"; 3 | import HighlightOffIcon from "@material-ui/icons/HighlightOff"; 4 | import InsertPhotoIcon from "@material-ui/icons/InsertPhoto"; 5 | import MenuIcon from "@material-ui/icons/Menu"; 6 | import React from "react"; 7 | import SendIcon from "@material-ui/icons/Send"; 8 | import SvgIcon from "@material-ui/core/SvgIcon"; 9 | import TextFieldsIcon from "@material-ui/icons/TextFields"; 10 | import TheatersIcon from "@material-ui/icons/Theaters"; 11 | import Tooltip from "@material-ui/core/Tooltip"; 12 | import TrendingUpIcon from "@material-ui/icons/TrendingUp"; 13 | import VisibilityIcon from "@material-ui/icons/Visibility"; 14 | import {theme} from "../page/style"; 15 | import {withStyles} from "@material-ui/core/styles"; 16 | 17 | interface EditorProps { 18 | remixId?: string; 19 | history: import("history").History; 20 | } 21 | 22 | const StyledTooltip = withStyles({ 23 | tooltip: { 24 | fontSize: theme.typography.body1.fontSize 25 | } 26 | })(Tooltip); 27 | 28 | interface EditorButtonProps { 29 | id: string; 30 | title: string; 31 | icon: typeof SvgIcon; 32 | } 33 | 34 | const EditorButton: React.FC = (props) => { 35 | const Icon = props.icon; 36 | return 42 | 43 | ; 44 | }; 45 | 46 | export default ((props) => { 47 | const div = React.useRef(); 48 | 49 | React.useEffect(() => { 50 | const editor = new Editor(div.current, props.history, props.remixId); 51 | return () => { 52 | editor.destroy(); 53 | }; 54 | }, []); 55 | 56 | return
57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
68 |
; 69 | }) as React.FC; 70 | -------------------------------------------------------------------------------- /frontend/src/editor/gizmo.ts: -------------------------------------------------------------------------------- 1 | import {Transform, Utility} from "./utility"; 2 | import Moveable from "moveable"; 3 | import {Widget} from "./manager"; 4 | 5 | export class Gizmo extends EventTarget { 6 | public readonly widget: Widget; 7 | 8 | public readonly moveable: Moveable; 9 | 10 | public constructor (widget: Widget) { 11 | super(); 12 | this.widget = widget; 13 | const moveable = new Moveable(document.body, { 14 | draggable: true, 15 | keepRatio: true, 16 | pinchable: true, 17 | rotatable: true, 18 | scalable: true, 19 | pinchOutside: true, 20 | pinchThreshold: Number.MAX_SAFE_INTEGER, 21 | target: widget.element 22 | }); 23 | this.moveable = moveable; 24 | 25 | moveable.on("rotateStart", ({set}) => { 26 | set(this.getTransform().rotate); 27 | }); 28 | moveable.on("rotate", ({beforeRotate}) => { 29 | this.setTransform({ 30 | ...this.getTransform(), 31 | rotate: beforeRotate 32 | }); 33 | }); 34 | moveable.on("dragStart", ({set}) => { 35 | set(this.getTransform().translate); 36 | }); 37 | moveable.on("drag", ({beforeTranslate}) => { 38 | this.setTransform({ 39 | ...this.getTransform(), 40 | translate: beforeTranslate as [number, number] 41 | }); 42 | }); 43 | moveable.on("renderEnd", (event) => { 44 | if (event.isDrag || event.isPinch) { 45 | this.emitKeyframe(); 46 | } else { 47 | this.widget.element.focus(); 48 | } 49 | }); 50 | moveable.on("scaleStart", ({set, dragStart}) => { 51 | set(this.getTransform().scale); 52 | if (dragStart) { 53 | dragStart.set(this.getTransform().translate); 54 | } 55 | }); 56 | moveable.on("scale", ({scale, drag}) => { 57 | this.setTransform({ 58 | ...this.getTransform(), 59 | scale: scale as [number, number], 60 | translate: drag.beforeTranslate as [number, number] 61 | }); 62 | }); 63 | } 64 | 65 | public emitKeyframe () { 66 | this.dispatchEvent(new Event("transformKeyframe")); 67 | } 68 | 69 | public destroy () { 70 | this.moveable.destroy(); 71 | this.dispatchEvent(new Event("destroy")); 72 | } 73 | 74 | public update () { 75 | this.moveable.updateTarget(); 76 | this.moveable.updateRect(); 77 | } 78 | 79 | public setTransform (state: Transform) { 80 | Utility.setTransform(this.widget.element, state); 81 | } 82 | 83 | public getTransform (): Transform { 84 | return Utility.getTransform(this.widget.element); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/editor/image.ts: -------------------------------------------------------------------------------- 1 | import gifFrames from "gif-frames"; 2 | 3 | export abstract class Image { 4 | public static setImage (element: HTMLImageElement, image: Image) { 5 | (element as any).patched_image = image; 6 | } 7 | 8 | public static getImage (element: HTMLImageElement): Image { 9 | return (element as any).patched_image; 10 | } 11 | 12 | public loadPromise: Promise; 13 | 14 | public abstract getFrameAtTime (time: number): HTMLCanvasElement | HTMLImageElement; 15 | } 16 | 17 | export class StaticImage extends Image { 18 | private img: HTMLImageElement; 19 | 20 | public constructor (url: string) { 21 | super(); 22 | this.img = document.createElement("img"); 23 | this.img.crossOrigin = "anonymous"; 24 | this.img.src = url; 25 | this.loadPromise = new Promise((resolve, reject) => { 26 | this.img.onload = resolve; 27 | this.img.onerror = reject; 28 | }); 29 | } 30 | 31 | public getFrameAtTime (): HTMLImageElement { 32 | return this.img; 33 | } 34 | } 35 | 36 | interface Frame { 37 | canvas: HTMLCanvasElement; 38 | delaySeconds: number; 39 | } 40 | 41 | export class Gif extends Image { 42 | private frames: Frame[]; 43 | 44 | private totalTime = 0; 45 | 46 | public constructor (url: string) { 47 | super(); 48 | 49 | const renderCumulativeFrames = (frameData: any[]) => { 50 | if (frameData.length === 0) { 51 | return frameData; 52 | } 53 | const previous = document.createElement("canvas"); 54 | const previousContext = previous.getContext("2d"); 55 | const current = document.createElement("canvas"); 56 | const currentContext = current.getContext("2d"); 57 | 58 | // Setting the canvas width will clear the canvas, so we only want to do it once. 59 | const firstFrameCanvas = frameData[0].getImage() as HTMLCanvasElement; 60 | 61 | // It also apperas that 'gif-frames' always returns a consistent sized canvas for all frames. 62 | previous.width = firstFrameCanvas.width; 63 | previous.height = firstFrameCanvas.height; 64 | current.width = firstFrameCanvas.width; 65 | current.height = firstFrameCanvas.height; 66 | 67 | for (const frame of frameData) { 68 | // Copy the current to the previous. 69 | previousContext.clearRect(0, 0, previous.width, previous.height); 70 | previousContext.drawImage(current, 0, 0); 71 | 72 | // Draw the current frame to the cumulative buffer. 73 | const canvas = frame.getImage() as HTMLCanvasElement; 74 | const context = canvas.getContext("2d"); 75 | currentContext.drawImage(canvas, 0, 0); 76 | context.clearRect(0, 0, canvas.width, canvas.height); 77 | context.drawImage(current, 0, 0); 78 | 79 | const {frameInfo} = frame; 80 | const {disposal} = frameInfo; 81 | // If the disposal method is clear to the background color, then clear the canvas. 82 | if (disposal === 2) { 83 | currentContext.clearRect(frameInfo.x, frameInfo.y, frameInfo.width, frameInfo.height); 84 | // If the disposal method is reset to the previous, then copy the previous over the current. 85 | } else if (disposal === 3) { 86 | currentContext.clearRect(0, 0, current.width, current.height); 87 | currentContext.drawImage(previous, 0, 0); 88 | } 89 | frame.getImage = () => canvas; 90 | } 91 | return frameData; 92 | }; 93 | 94 | const frameDataPromise = gifFrames({ 95 | cumulative: false, 96 | frames: "all", 97 | outputType: "canvas", 98 | url 99 | }).then((frameData) => renderCumulativeFrames(frameData)) as Promise; 100 | 101 | this.loadPromise = frameDataPromise.then((frameData: any[]) => { 102 | this.frames = frameData.map((frame) => ({ 103 | canvas: frame.getImage() as HTMLCanvasElement, 104 | // This delay exactly mimics Chrome / Firefox frame delay behavior (0 or 1 means 10). 105 | delaySeconds: frame.frameInfo.delay <= 1 106 | ? 10 / 100 107 | : frame.frameInfo.delay / 100 108 | })); 109 | 110 | for (const frame of this.frames) { 111 | this.totalTime += frame.delaySeconds; 112 | } 113 | this.totalTime = Math.max(this.totalTime, 0.01); 114 | }); 115 | } 116 | 117 | public getFrameAtTime (time: number): HTMLCanvasElement { 118 | const clampedTime = time % this.totalTime; 119 | let seekTime = 0; 120 | for (let i = 0; i < this.frames.length; ++i) { 121 | const frame = this.frames[i]; 122 | if (seekTime + frame.delaySeconds > clampedTime) { 123 | return frame.canvas; 124 | } 125 | seekTime += frame.delaySeconds; 126 | } 127 | return this.frames[this.frames.length - 1].canvas; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /frontend/src/editor/manager.ts: -------------------------------------------------------------------------------- 1 | import {AnimationData, Track, Tracks, WidgetInit} from "../../../common/common"; 2 | import {Gif, Image, StaticImage} from "./image"; 3 | import {RELATIVE_WIDGET_SIZE, Size, TimeRange, UPDATE, Utility, getAspect, resizeMinimumKeepAspect} from "./utility"; 4 | import {Background} from "./background"; 5 | import {Gizmo} from "./gizmo"; 6 | import {Renderer} from "./renderer"; 7 | import {Spinner} from "./spinner"; 8 | import {Timeline} from "./timeline"; 9 | import {VideoPlayer} from "./videoPlayer"; 10 | import {setHasUnsavedChanges} from "../shared/unload"; 11 | // eslint-disable-next-line @typescript-eslint/no-var-requires 12 | const uuidv4: typeof import("uuid/v4") = require("uuid/v4"); 13 | 14 | const FakeFrameTime = -1; 15 | 16 | export type ElementFactory = (id: string) => Promise; 17 | 18 | export class Widget { 19 | public readonly element: HTMLElement; 20 | 21 | public readonly init: WidgetInit; 22 | 23 | /** @internal */ 24 | public constructor (element: HTMLElement, init: WidgetInit) { 25 | this.element = element; 26 | this.init = init; 27 | } 28 | } 29 | 30 | export class Manager { 31 | private tracks: Tracks = {}; 32 | 33 | private widgetContainer: HTMLDivElement; 34 | 35 | private videoPlayer: VideoPlayer; 36 | 37 | private timeline: Timeline; 38 | 39 | public selection: Gizmo = null; 40 | 41 | private widgets: Widget[] = []; 42 | 43 | private readonly renderer: Renderer; 44 | 45 | public updateExternally = false; 46 | 47 | public readonly spinner = new Spinner(); 48 | 49 | private requestedAnimationFrame: number; 50 | 51 | public constructor ( 52 | background: Background, 53 | container: HTMLDivElement, 54 | widgetContainer: HTMLDivElement, 55 | videoPlayer: VideoPlayer, 56 | timeline: Timeline, 57 | renderer: Renderer 58 | ) { 59 | this.widgetContainer = widgetContainer; 60 | this.videoPlayer = videoPlayer; 61 | this.timeline = timeline; 62 | this.renderer = renderer; 63 | this.update(); 64 | 65 | const updateContainerSize = (aspectSize: Size, scale: number) => { 66 | container.style.width = `${aspectSize[0]}px`; 67 | container.style.height = `${aspectSize[1]}px`; 68 | container.style.transform = `translate(${0}px, ${0}px) scale(${scale})`; 69 | 70 | const width = aspectSize[0] * scale; 71 | const height = aspectSize[1] * scale; 72 | 73 | container.style.left = `${(window.innerWidth - width) / 2}px`; 74 | container.style.top = `${(window.innerHeight - height) / 2}px`; 75 | }; 76 | 77 | const onResize = () => { 78 | const aspectSize = videoPlayer.getAspectSize(); 79 | const windowSize: Size = [ 80 | window.innerWidth, 81 | window.innerHeight 82 | ]; 83 | if (getAspect(aspectSize) > getAspect(windowSize)) { 84 | updateContainerSize(aspectSize, window.innerWidth / aspectSize[0]); 85 | } else { 86 | updateContainerSize(aspectSize, window.innerHeight / aspectSize[1]); 87 | } 88 | }; 89 | videoPlayer.video.addEventListener("canplay", onResize); 90 | window.addEventListener("resize", onResize); 91 | onResize(); 92 | 93 | const onUpdate = () => { 94 | this.requestedAnimationFrame = requestAnimationFrame(onUpdate); 95 | window.dispatchEvent(new Event(UPDATE)); 96 | this.update(); 97 | }; 98 | onUpdate(); 99 | 100 | const deselectElement = (event: Event) => { 101 | if (event.target === widgetContainer || event.target === background.canvas) { 102 | // If we're touching down, only deselect if it's the only touch (we may be dragging the gizmo). 103 | if (window.TouchEvent && event instanceof TouchEvent ? event.touches.length === 1 : true) { 104 | this.selectWidget(null); 105 | } 106 | } 107 | }; 108 | 109 | const onKeyDown = (event) => { 110 | if (event.key === "Delete") { 111 | this.attemptDeleteSelection(); 112 | } 113 | }; 114 | 115 | const registerInputEvents = (element: HTMLElement) => { 116 | element.addEventListener("mousedown", deselectElement); 117 | element.addEventListener("touchstart", deselectElement); 118 | element.addEventListener("keydown", onKeyDown); 119 | }; 120 | registerInputEvents(widgetContainer); 121 | registerInputEvents(background.canvas); 122 | registerInputEvents(document.body); 123 | } 124 | 125 | public attemptDeleteSelection () { 126 | if (this.selection) { 127 | this.destroyWidget(this.selection.widget); 128 | } 129 | } 130 | 131 | public updateMarkers () { 132 | if (this.selection) { 133 | const track = this.tracks[`#${this.selection.widget.element.id}`]; 134 | this.videoPlayer.setMarkers(Object.keys(track).map((str) => parseFloat(str))); 135 | } else { 136 | this.videoPlayer.setMarkers([]); 137 | } 138 | } 139 | 140 | public updateChanges () { 141 | const tracksCopy: Tracks = JSON.parse(JSON.stringify(this.tracks)); 142 | for (const track of Object.values(tracksCopy)) { 143 | let hasTransform = false; 144 | 145 | for (const keyframe of Object.values(track)) { 146 | if (keyframe.transform) { 147 | hasTransform = true; 148 | } 149 | } 150 | 151 | // It's better to always have a frame of visibility so that the user can add a keyframe that hides it. 152 | track[FakeFrameTime] = {clip: "auto"}; 153 | if (!hasTransform) { 154 | track[FakeFrameTime].transform = this.centerTransform(); 155 | } 156 | } 157 | 158 | this.timeline.updateTracks(tracksCopy); 159 | this.updateMarkers(); 160 | } 161 | 162 | public save (): AnimationData { 163 | return { 164 | tracks: JSON.parse(JSON.stringify(this.tracks)), 165 | videoAttributedSource: this.videoPlayer.getAttributedSrc(), 166 | widgets: this.widgets.map((widget) => JSON.parse(JSON.stringify(widget.init))) 167 | }; 168 | } 169 | 170 | public async load (data: AnimationData) { 171 | this.videoPlayer.setAttributedSrc(data.videoAttributedSource); 172 | this.clearWidgets(); 173 | for (const init of data.widgets) { 174 | await this.addWidget(init); 175 | } 176 | this.selectWidget(null); 177 | this.tracks = data.tracks; 178 | this.updateChanges(); 179 | // Force a change so everything updates 180 | this.timeline.setNormalizedTime(1); 181 | this.timeline.setNormalizedTime(0); 182 | this.videoPlayer.video.currentTime = 0; 183 | await this.videoPlayer.loadPromise; 184 | setHasUnsavedChanges(false); 185 | } 186 | 187 | private update () { 188 | if (this.updateExternally) { 189 | return; 190 | } 191 | const normalizedCurrentTime = this.videoPlayer.getNormalizedCurrentTime(); 192 | this.timeline.setNormalizedTime(normalizedCurrentTime); 193 | if (this.selection) { 194 | this.selection.update(); 195 | } 196 | this.renderer.drawFrame(this.videoPlayer.video.currentTime, false); 197 | } 198 | 199 | private centerTransform () { 200 | return Utility.transformToCss(Utility.centerTransform(this.videoPlayer.getAspectSize())); 201 | } 202 | 203 | public async addWidget (init: WidgetInit): Promise { 204 | this.spinner.show(); 205 | const element = await (async () => { 206 | const img = document.createElement("img"); 207 | img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; 208 | const {src} = init.attributedSource; 209 | const image = init.attributedSource.mimeType === "image/gif" ? new Gif(src) : new StaticImage(src); 210 | Image.setImage(img, image); 211 | await image.loadPromise; 212 | const frame = image.getFrameAtTime(0); 213 | const size = resizeMinimumKeepAspect([frame.width, frame.height], [RELATIVE_WIDGET_SIZE, RELATIVE_WIDGET_SIZE]); 214 | [img.width, img.height] = size; 215 | img.style.left = `${-size[0] / 2}px`; 216 | img.style.top = `${-size[1] / 2}px`; 217 | return img; 218 | })(); 219 | 220 | let track: Track = {}; 221 | if (!init.id) { 222 | // Replace the current widget if any is selected. 223 | if (this.selection) { 224 | init.id = this.selection.widget.init.id; 225 | track = this.tracks[`#${init.id}`]; 226 | this.destroyWidget(this.selection.widget); 227 | } else { 228 | init.id = `id-${uuidv4()}`; 229 | } 230 | } 231 | 232 | const {id} = init; 233 | if (this.tracks[`#${id}`]) { 234 | this.spinner.hide(); 235 | throw new Error(`Widget id already exists: ${id}`); 236 | } 237 | 238 | element.id = id; 239 | element.className = "widget"; 240 | element.draggable = false; 241 | element.ondragstart = (event) => { 242 | event.preventDefault(); 243 | return false; 244 | }; 245 | element.style.transform = this.centerTransform(); 246 | element.style.clip = "auto"; 247 | this.widgetContainer.appendChild(element); 248 | 249 | this.tracks[`#${id}`] = track; 250 | this.updateChanges(); 251 | setHasUnsavedChanges(true); 252 | const widget = new Widget(element, init); 253 | this.widgets.push(widget); 254 | 255 | const grabElement = (event) => { 256 | this.selectWidget(widget); 257 | this.selection.moveable.dragStart(event); 258 | }; 259 | element.addEventListener("mousedown", grabElement, true); 260 | element.addEventListener("touchstart", grabElement, true); 261 | 262 | this.selectWidget(widget); 263 | this.spinner.hide(); 264 | return widget; 265 | } 266 | 267 | private isSelected (widget?: Widget) { 268 | if (this.selection === null && widget === null) { 269 | return true; 270 | } 271 | if (this.selection && widget && this.selection.widget.element === widget.element) { 272 | return true; 273 | } 274 | return false; 275 | } 276 | 277 | public selectWidget (widget?: Widget) { 278 | if (this.isSelected(widget)) { 279 | return; 280 | } 281 | if (this.selection) { 282 | this.selection.destroy(); 283 | this.selection = null; 284 | } 285 | if (widget) { 286 | this.widgetContainer.focus(); 287 | this.selection = new Gizmo(widget); 288 | // We intentionally do not call removeEventListener because we already create a new Gizmo each time. 289 | this.selection.addEventListener( 290 | "transformKeyframe", 291 | // We also do not use `this.selection.widget` because we clear selection on motion capture. 292 | () => this.keyframe(widget.element, "transform") 293 | ); 294 | } 295 | this.updateMarkers(); 296 | } 297 | 298 | public destroyWidget (widget: Widget) { 299 | if (this.isSelected(widget)) { 300 | this.selectWidget(null); 301 | } 302 | widget.element.remove(); 303 | delete this.tracks[`#${widget.init.id}`]; 304 | this.updateChanges(); 305 | setHasUnsavedChanges(true); 306 | this.widgets.splice(this.widgets.indexOf(widget), 1); 307 | } 308 | 309 | public clearWidgets () { 310 | while (this.widgets.length !== 0) { 311 | this.destroyWidget(this.widgets[0]); 312 | } 313 | } 314 | 315 | public toggleVisibility (element: HTMLElement) { 316 | const {style} = element; 317 | style.clip = style.clip === "auto" ? "unset" : "auto"; 318 | this.keyframe(element, "clip"); 319 | } 320 | 321 | private keyframe (element: HTMLElement, type: "clip" | "transform") { 322 | const track = this.tracks[`#${element.id}`]; 323 | const normalizedTime = this.videoPlayer.getNormalizedCurrentTime(); 324 | const existingKeyframe = track[normalizedTime]; 325 | 326 | const newKeyframe = type === "clip" 327 | ? {clip: element.style.clip} 328 | : {transform: Utility.transformToCss(Utility.getTransform(element))}; 329 | 330 | // If on the same frame where a 'clip' existed you add a 'transform', this keeps both. 331 | track[normalizedTime] = {...existingKeyframe, ...newKeyframe}; 332 | this.updateChanges(); 333 | setHasUnsavedChanges(true); 334 | } 335 | 336 | 337 | public deleteKeyframesInRange (widgetTrackId: string, range: TimeRange) { 338 | let result = false; 339 | const track = this.tracks[widgetTrackId]; 340 | if (track) { 341 | for (const normalizedTimeStr of Object.keys(track)) { 342 | const normalizedTime = parseFloat(normalizedTimeStr); 343 | if (normalizedTime >= range[0] && normalizedTime <= range[1]) { 344 | delete track[normalizedTimeStr]; 345 | result = true; 346 | } 347 | } 348 | } 349 | return result; 350 | } 351 | 352 | public destroy () { 353 | this.selectWidget(null); 354 | cancelAnimationFrame(this.requestedAnimationFrame); 355 | setHasUnsavedChanges(false); 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /frontend/src/editor/modal.tsx: -------------------------------------------------------------------------------- 1 | import {Deferred, NonAlertingError} from "../shared/shared"; 2 | import Button from "@material-ui/core/Button"; 3 | import CloseIcon from "@material-ui/icons/Close"; 4 | import Dialog from "@material-ui/core/Dialog"; 5 | import DialogActions from "@material-ui/core/DialogActions"; 6 | import DialogContent from "@material-ui/core/DialogContent"; 7 | import DialogTitle from "@material-ui/core/DialogTitle"; 8 | import IconButton from "@material-ui/core/IconButton"; 9 | import React from "react"; 10 | import Typography from "@material-ui/core/Typography"; 11 | import {useStyles} from "../page/style"; 12 | 13 | export const MODALS_CHANGED = "modalsChanged"; 14 | 15 | export type ModalCallback = (button: ModalButton) => unknown; 16 | 17 | export interface ModalButton { 18 | name: string; 19 | 20 | dismiss?: boolean; 21 | 22 | callback?: ModalCallback; 23 | 24 | submitOnEnter?: boolean; 25 | } 26 | 27 | export interface ModalOpenParameters { 28 | title?: string; 29 | titleImageUrl?: string; 30 | dismissable?: boolean; 31 | fullscreen?: boolean; 32 | buttons?: ModalButton[]; 33 | render? (): React.ReactNode; 34 | onShown?: () => unknown; 35 | } 36 | 37 | export interface ModalProps extends ModalOpenParameters { 38 | id: number; 39 | defer: Deferred; 40 | } 41 | 42 | export const allModals: ModalProps[] = []; 43 | let modalIdCounter = 0; 44 | 45 | const removeModalInternal = (id: number) => { 46 | const index = allModals.findIndex((modal) => modal.id === id); 47 | if (index !== -1) { 48 | allModals[index].defer.resolve(null); 49 | allModals.splice(index, 1); 50 | window.dispatchEvent(new Event(MODALS_CHANGED)); 51 | } 52 | }; 53 | 54 | export const ModalComponent: React.FC = (props) => { 55 | const classes = useStyles(); 56 | const hasSubmitButton = Boolean(props.buttons && props.buttons.find((button) => button.submitOnEnter)); 57 | const content =
58 | 59 | { props.children } 60 |
{ 61 | if (props.onShown) { 62 | props.onShown(); 63 | } 64 | }}> 65 | {props.render ? props.render() : null} 66 |
67 |
68 | 69 | { 70 | (props.buttons || []).map((button) => ) 90 | } 91 | 92 |
; 93 | 94 | return removeModalInternal(props.id)} 100 | fullScreen={props.fullscreen} 101 | aria-labelledby="alert-dialog-title" 102 | aria-describedby="alert-dialog-description" 103 | > 104 | 105 | {props.title} 106 | {props.titleImageUrl 107 | ? 108 | : null} 109 | { 110 | props.dismissable 111 | ? removeModalInternal(props.id)}> 115 | 116 | 117 | : null 118 | } 119 | 120 | { 121 | hasSubmitButton 122 | ? e.preventDefault()}>{content} 123 | : content 124 | } 125 | ; 126 | }; 127 | 128 | export const ModalContainer: React.FC = () => { 129 | const [ 130 | modals, 131 | setModals 132 | ] = React.useState(allModals); 133 | React.useEffect(() => { 134 | const onModalsChanged = () => { 135 | setModals([...allModals]); 136 | }; 137 | window.addEventListener(MODALS_CHANGED, onModalsChanged); 138 | return () => { 139 | window.removeEventListener(MODALS_CHANGED, onModalsChanged); 140 | }; 141 | }, []); 142 | return
{modals.map((modal) => )}
; 143 | }; 144 | 145 | export class Modal { 146 | private id = modalIdCounter++; 147 | 148 | public async open (params: ModalOpenParameters): Promise { 149 | const defer = new Deferred(); 150 | allModals.push({ 151 | ...params, 152 | defer, 153 | id: this.id 154 | }); 155 | window.dispatchEvent(new Event(MODALS_CHANGED)); 156 | return defer; 157 | } 158 | 159 | public hide () { 160 | removeModalInternal(this.id); 161 | } 162 | 163 | public static async messageBox (title: string, text: string): Promise { 164 | const modal = new Modal(); 165 | return modal.open({ 166 | buttons: [ 167 | { 168 | dismiss: true, 169 | name: "Close" 170 | } 171 | ], 172 | render: () => {text}, 173 | dismissable: true, 174 | title 175 | }); 176 | } 177 | } 178 | 179 | const displayError = (error: any) => { 180 | // Only show the error if we're not already showing another modal. 181 | if (allModals.length === 0) { 182 | const getError = (errorClass: Error) => errorClass instanceof NonAlertingError ? null : errorClass.message; 183 | const message = (() => { 184 | if (error instanceof Error) { 185 | return getError(error); 186 | } 187 | if (error instanceof PromiseRejectionEvent) { 188 | if (error.reason instanceof Error) { 189 | return getError(error.reason); 190 | } 191 | return `${error.reason}`; 192 | } 193 | return `${error}`; 194 | })(); 195 | 196 | if (message) { 197 | Modal.messageBox("Error", message); 198 | } 199 | } 200 | }; 201 | 202 | window.onunhandledrejection = (error) => displayError(error); 203 | window.onerror = (message, source, lineno, colno, error) => displayError(error || message); 204 | -------------------------------------------------------------------------------- /frontend/src/editor/modalProgress.tsx: -------------------------------------------------------------------------------- 1 | import {Modal, ModalButton, ModalOpenParameters} from "./modal"; 2 | import LinearProgress from "@material-ui/core/LinearProgress"; 3 | import React from "react"; 4 | import Typography from "@material-ui/core/Typography"; 5 | 6 | export class ModalProgress extends Modal { 7 | public setProgress: (progress: number, status: string) => void = () => 0; 8 | 9 | public async open (params: ModalOpenParameters): Promise { 10 | const {render} = params; 11 | params.render = () => { 12 | const [ 13 | state, 14 | setState 15 | ] = React.useState({progress: 0, status: ""}); 16 | 17 | React.useEffect(() => () => { 18 | this.setProgress = () => 0; 19 | }, []); 20 | 21 | this.setProgress = (progress, status) => setState({progress, status}); 22 | return
23 | {render ? render() : null} 24 | 25 | {state.status} 26 | 27 | 28 |
; 29 | }; 30 | return super.open(params); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/editor/motionTracker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | import {VideoSeeker, VideoSeekerFrame} from "./videoSeeker"; 3 | import {VideoPlayer} from "./videoPlayer"; 4 | import jsfeat from "jsfeat"; 5 | 6 | export class MotionTrackerEvent { 7 | public progress: number; 8 | 9 | public found: boolean; 10 | 11 | public x: number; 12 | 13 | public y: number; 14 | } 15 | 16 | export class MotionTracker extends VideoSeeker { 17 | private readonly canvas = document.createElement("canvas"); 18 | 19 | private readonly context: CanvasRenderingContext2D; 20 | 21 | private currentPyramid = new jsfeat.pyramid_t(3); 22 | 23 | private previousPyramid = new jsfeat.pyramid_t(3); 24 | 25 | private readonly pointStatus = new Uint8Array(100); 26 | 27 | private previousXY = new Float32Array(100 * 2); 28 | 29 | private currentXY = new Float32Array(100 * 2); 30 | 31 | private pointCount = 0; 32 | 33 | private readonly windowSize = 40; 34 | 35 | private readonly maxIterations = 50; 36 | 37 | private readonly epsilon = 0.1; 38 | 39 | private readonly minEigen = 0.0001; 40 | 41 | public onMotionFrame: (event: MotionTrackerEvent) => Promise; 42 | 43 | public constructor (player: VideoPlayer) { 44 | super(player); 45 | this.context = this.canvas.getContext("2d"); 46 | } 47 | 48 | public async track () { 49 | await this.player.loadPromise; 50 | const size = this.player.getAspectSize(); 51 | 52 | this.currentPyramid.allocate(size[0], size[1], jsfeat.U8_t | jsfeat.C1_t); 53 | this.previousPyramid.allocate(size[0], size[1], jsfeat.U8_t | jsfeat.C1_t); 54 | 55 | [ 56 | this.canvas.width, 57 | this.canvas.height 58 | ] = size; 59 | 60 | this.buildPyramidFromVideoImage(this.currentPyramid); 61 | 62 | await this.run(this.player.video.currentTime); 63 | } 64 | 65 | public addPoint (x: number, y: number) { 66 | this.currentXY[this.pointCount << 1] = x; 67 | this.currentXY[(this.pointCount << 1) + 1] = y; 68 | ++this.pointCount; 69 | } 70 | 71 | private buildPyramidFromVideoImage (pyramid: any) { 72 | const size = this.player.getAspectSize(); 73 | 74 | this.context.drawImage(this.player.video, 0, 0, size[0], size[1]); 75 | const imageData = this.context.getImageData(0, 0, size[0], size[1]); 76 | 77 | const [currentData] = pyramid.data; 78 | jsfeat.imgproc.grayscale(imageData.data, size[0], size[1], currentData); 79 | 80 | pyramid.build(currentData, true); 81 | } 82 | 83 | protected async onFrame (frame: VideoSeekerFrame) { 84 | const tempXY = this.previousXY; 85 | this.previousXY = this.currentXY; 86 | this.currentXY = tempXY; 87 | 88 | const tempPyramid = this.previousPyramid; 89 | this.previousPyramid = this.currentPyramid; 90 | this.currentPyramid = tempPyramid; 91 | 92 | this.buildPyramidFromVideoImage(this.currentPyramid); 93 | 94 | jsfeat.optical_flow_lk.track( 95 | this.previousPyramid, 96 | this.currentPyramid, 97 | this.previousXY, 98 | this.currentXY, 99 | this.pointCount, 100 | this.windowSize, 101 | this.maxIterations, 102 | this.pointStatus, 103 | this.epsilon, 104 | this.minEigen 105 | ); 106 | 107 | this.prunePoints(); 108 | 109 | const toSend = new MotionTrackerEvent(); 110 | if (this.pointCount !== 0) { 111 | toSend.found = true; 112 | [ 113 | toSend.x, 114 | toSend.y 115 | ] = this.currentXY; 116 | } 117 | toSend.progress = frame.progress; 118 | await this.onMotionFrame(toSend); 119 | } 120 | 121 | private prunePoints () { 122 | let i = 0; 123 | let j = 0; 124 | 125 | for (; i < this.pointCount; ++i) { 126 | if (this.pointStatus[i] === 1) { 127 | if (j < i) { 128 | this.currentXY[j << 1] = this.currentXY[i << 1]; 129 | this.currentXY[(j << 1) + 1] = this.currentXY[(i << 1) + 1]; 130 | } 131 | ++j; 132 | } 133 | } 134 | this.pointCount = j; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /frontend/src/editor/renderer.ts: -------------------------------------------------------------------------------- 1 | import {MAX_OUTPUT_SIZE, Utility, resizeMinimumKeepAspect} from "./utility"; 2 | import {VideoSeeker, VideoSeekerFrame} from "./videoSeeker"; 3 | import {Image} from "./image"; 4 | import {Timeline} from "./timeline"; 5 | import {VideoPlayer} from "./videoPlayer"; 6 | 7 | export class RenderFrameEvent { 8 | public progress: number; 9 | } 10 | 11 | export class Renderer extends VideoSeeker { 12 | private readonly canvas: HTMLCanvasElement; 13 | 14 | private readonly context: CanvasRenderingContext2D; 15 | 16 | public readonly resizeCanvas: HTMLCanvasElement; 17 | 18 | public readonly resizeContext: CanvasRenderingContext2D; 19 | 20 | private readonly widgetContainer: HTMLDivElement; 21 | 22 | private readonly timeline: Timeline; 23 | 24 | public onRenderFrame: (event: RenderFrameEvent) => Promise; 25 | 26 | public constructor ( 27 | canvas: HTMLCanvasElement, 28 | widgetContainer: HTMLDivElement, 29 | player: VideoPlayer, 30 | timeline: Timeline 31 | ) { 32 | super(player); 33 | this.canvas = canvas; 34 | this.context = this.canvas.getContext("2d"); 35 | this.widgetContainer = widgetContainer; 36 | 37 | this.resizeCanvas = document.createElement("canvas"); 38 | this.resizeContext = this.resizeCanvas.getContext("2d"); 39 | 40 | player.addEventListener("srcChanged", () => this.updateResizeCanvsaSize()); 41 | this.updateResizeCanvsaSize(); 42 | this.timeline = timeline; 43 | } 44 | 45 | public drawFrame (currentTime: number, finalRender: boolean) { 46 | const size = this.player.getAspectSize(); 47 | [ 48 | this.canvas.width, 49 | this.canvas.height 50 | ] = size; 51 | this.context.clearRect(0, 0, size[0], size[1]); 52 | 53 | for (const child of this.widgetContainer.childNodes) { 54 | if (child instanceof HTMLImageElement) { 55 | const hidden = child.style.clip !== "auto"; 56 | if (hidden && finalRender) { 57 | continue; 58 | } 59 | const transform = Utility.getTransform(child); 60 | this.context.translate(transform.translate[0], transform.translate[1]); 61 | this.context.rotate(transform.rotate * Math.PI / 180); 62 | this.context.scale(transform.scale[0], transform.scale[1]); 63 | const bitmap = Image.getImage(child).getFrameAtTime(currentTime); 64 | this.context.globalAlpha = hidden ? 0.3 : 1; 65 | this.context.drawImage(bitmap, -child.width / 2, -child.height / 2, child.width, child.height); 66 | this.context.resetTransform(); 67 | } 68 | } 69 | } 70 | 71 | private updateResizeCanvsaSize () { 72 | const size = resizeMinimumKeepAspect(this.player.getRawSize(), MAX_OUTPUT_SIZE); 73 | [ 74 | this.resizeCanvas.width, 75 | this.resizeCanvas.height 76 | ] = size; 77 | } 78 | 79 | protected async onFrame (frame: VideoSeekerFrame) { 80 | this.timeline.setNormalizedTime(frame.normalizedCurrentTime); 81 | this.updateResizeCanvsaSize(); 82 | this.drawFrame(frame.currentTime, true); 83 | this.resizeContext.drawImage(this.player.video, 0, 0, this.resizeCanvas.width, this.resizeCanvas.height); 84 | this.resizeContext.drawImage(this.canvas, 0, 0, this.resizeCanvas.width, this.resizeCanvas.height); 85 | const toSend = new RenderFrameEvent(); 86 | toSend.progress = frame.progress; 87 | await this.onRenderFrame(toSend); 88 | } 89 | 90 | public async render (): Promise { 91 | return this.run(0); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /frontend/src/editor/spinner.css: -------------------------------------------------------------------------------- 1 | .spinner-fullscreen { 2 | width: 100%; 3 | height: 100%; 4 | position: absolute; 5 | background: #aaaaaa; 6 | opacity: .5; 7 | z-index: 1000000; 8 | display: flex; 9 | text-align: center; 10 | justify-content: center; 11 | align-items: center; 12 | overflow: hidden; 13 | } 14 | 15 | .spinner { 16 | width: 70px; 17 | text-align: center; 18 | } 19 | 20 | .spinner > div { 21 | width: 18px; 22 | height: 18px; 23 | background-color: #333; 24 | 25 | border-radius: 100%; 26 | display: inline-block; 27 | -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both; 28 | animation: sk-bouncedelay 1.4s infinite ease-in-out both; 29 | } 30 | 31 | .spinner .bounce1 { 32 | -webkit-animation-delay: -0.32s; 33 | animation-delay: -0.32s; 34 | } 35 | 36 | .spinner .bounce2 { 37 | -webkit-animation-delay: -0.16s; 38 | animation-delay: -0.16s; 39 | } 40 | 41 | @-webkit-keyframes sk-bouncedelay { 42 | 0%, 80%, 100% { -webkit-transform: scale(0) } 43 | 40% { -webkit-transform: scale(1.0) } 44 | } 45 | 46 | @keyframes sk-bouncedelay { 47 | 0%, 80%, 100% { 48 | -webkit-transform: scale(0); 49 | transform: scale(0); 50 | } 40% { 51 | -webkit-transform: scale(1.0); 52 | transform: scale(1.0); 53 | } 54 | } -------------------------------------------------------------------------------- /frontend/src/editor/spinner.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
-------------------------------------------------------------------------------- /frontend/src/editor/spinner.ts: -------------------------------------------------------------------------------- 1 | import "./spinner.css"; 2 | import $ from "jquery"; 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const html = require("./spinner.html").default; 5 | 6 | export class Spinner { 7 | private root: JQuery; 8 | 9 | private complete: JQuery = $(); 10 | 11 | private count = 0; 12 | 13 | private id = 0; 14 | 15 | public constructor () { 16 | this.root = $(html); 17 | } 18 | 19 | public show () { 20 | if (this.count === 0) { 21 | this.complete.remove(); 22 | $(document.body).append(this.root); 23 | } 24 | ++this.count; 25 | } 26 | 27 | public hide () { 28 | if (this.count === 1) { 29 | this.root.remove(); 30 | this.complete = $(`
`); 31 | $(document.body).append(this.complete); 32 | } 33 | --this.count; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/editor/stickerSearch.tsx: -------------------------------------------------------------------------------- 1 | import {Auth, Deferred, abortableJsonFetch, cancel} from "../shared/shared"; 2 | import {theme, useStyles} from "../page/style"; 3 | import {AttributedSource} from "../../../common/common"; 4 | import Button from "@material-ui/core/Button"; 5 | import InputBase from "@material-ui/core/InputBase"; 6 | import Masonry from "react-masonry-css"; 7 | import {Modal} from "./modal"; 8 | import React from "react"; 9 | import SearchIcon from "@material-ui/icons/Search"; 10 | 11 | export type StickerType = "stickers" | "gifs"; 12 | 13 | const API_KEY = "s9bgj4fh1ZldOfMHEWrQCekTy0BIKuko"; 14 | 15 | interface StickerSearchBodyProps { 16 | type: StickerType; 17 | onSelect: (item: AttributedSource) => void; 18 | } 19 | 20 | export const StickerSearchBody: React.FC = (props) => { 21 | const [images, setImages] = React.useState([]); 22 | const [searchText, setSearchText] = React.useState(""); 23 | 24 | React.useEffect(() => { 25 | const endpoint = searchText ? "search" : "trending"; 26 | const url = new URL(`https://api.giphy.com/v1/${props.type}/${endpoint}`); 27 | url.searchParams.set("api_key", API_KEY); 28 | url.searchParams.set("q", searchText); 29 | url.searchParams.set("limit", "60"); 30 | url.searchParams.set("rating", "pg"); 31 | const fetchPromise = abortableJsonFetch(url.href, Auth.None, null, null, { 32 | method: "GET" 33 | }); 34 | (async () => { 35 | const result = await fetchPromise; 36 | if (result) { 37 | setImages(result.data); 38 | } 39 | })(); 40 | 41 | return () => { 42 | cancel(fetchPromise); 43 | }; 44 | }, [searchText]); 45 | 46 | const classes = useStyles(); 47 | return
48 |
49 |
50 |
51 | 52 |
53 | setSearchText(event.target.value)} 57 | classes={{ 58 | root: classes.searchInputRoot, 59 | input: classes.searchInputInput 60 | }} 61 | inputProps={{"aria-label": "search"}} 62 | /> 63 |
64 | 96 |
97 | 110 | {images.map((image) =>
111 | props.onSelect({ 115 | originUrl: image.url, 116 | title: image.title, 117 | previewUrl: image.images.preview_gif.url, 118 | src: image.images.original[props.type === "stickers" ? "url" : "mp4"] as string, 119 | mimeType: "image/gif" 120 | })}/> 121 |
)} 122 |
123 |
; 124 | }; 125 | 126 | export class StickerSearch { 127 | public static async searchForStickerUrl (type: StickerType): Promise { 128 | const modal = new Modal(); 129 | const waitForShow = new Deferred(); 130 | 131 | const defer = new Deferred(); 132 | const modalPromise: Promise = modal.open({ 133 | // eslint-disable-next-line react/display-name 134 | render: () => 135 | defer.resolve(item)}/>, 138 | dismissable: true, 139 | fullscreen: true, 140 | titleImageUrl: require("../public/giphy.png").default, 141 | onShown: () => waitForShow.resolve() 142 | }).then(() => null); 143 | 144 | await waitForShow; 145 | 146 | const result = await Promise.race([ 147 | modalPromise, 148 | defer 149 | ]); 150 | 151 | modal.hide(); 152 | return result; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /frontend/src/editor/timeline.ts: -------------------------------------------------------------------------------- 1 | import Scene, {Frame} from "scenejs"; 2 | import {Tracks} from "../../../common/common"; 3 | 4 | export class TimelineEvent extends Event { 5 | public readonly frame: Frame; 6 | 7 | public constructor (type: string, frame: Frame) { 8 | super(type); 9 | this.frame = frame; 10 | } 11 | } 12 | 13 | export class Timeline { 14 | private scene: Scene; 15 | 16 | private normalizedTime = 0; 17 | 18 | public constructor () { 19 | this.updateTracks({}); 20 | this.scene.on("animate", (event) => { 21 | // eslint-disable-next-line guard-for-in 22 | for (const selector in event.frames) { 23 | const frame: Frame = event.frames[selector]; 24 | const element = document.querySelector(selector); 25 | if (element) { 26 | element.dispatchEvent(new TimelineEvent("frame", frame)); 27 | } 28 | } 29 | }); 30 | } 31 | 32 | public getNormalizedTime () { 33 | return this.normalizedTime; 34 | } 35 | 36 | public setNormalizedTime (normalizedTime: number) { 37 | if (this.normalizedTime !== normalizedTime) { 38 | this.scene.setTime(normalizedTime); 39 | this.normalizedTime = normalizedTime; 40 | } 41 | } 42 | 43 | public updateTracks (tracks: Tracks) { 44 | this.scene = new Scene(tracks, { 45 | easing: "linear", 46 | selector: true 47 | }); 48 | this.scene.setTime(this.normalizedTime); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/editor/utility.ts: -------------------------------------------------------------------------------- 1 | import {MAX_VIDEO_SIZE} from "../../../common/common"; 2 | 3 | export const FRAME_RATE = 24; 4 | export const FRAME_TIME = 1 / FRAME_RATE; 5 | export const DURATION_PER_ENCODE = 1; 6 | export const MAX_OUTPUT_SIZE: Size = [ 7 | MAX_VIDEO_SIZE, 8 | MAX_VIDEO_SIZE 9 | ]; 10 | 11 | /** 12 | * Do NOT change these constants, this would be a breaking change as they 13 | * affect sizes of widgets and positioning within the video. When the animations 14 | * are serialized all their positions are relative to these constants. 15 | * Note also that they cannot be small (less than 22) as that starts to 16 | * cause browser artifacts, presumingly due to the use of transforms. 17 | */ 18 | export const RELATIVE_WIDGET_SIZE = 400; 19 | export const RELATIVE_VIDEO_SIZE = 1280; 20 | 21 | export const UPDATE = "update"; 22 | 23 | export interface Transform { 24 | rotate: number; 25 | scale: [number, number]; 26 | translate: [number, number]; 27 | } 28 | 29 | export class Utility { 30 | public static transformToCss (state: Transform): string { 31 | return `translate(${state.translate[0]}px, ${state.translate[1]}px) ` + 32 | `rotate(${state.rotate}deg) ` + 33 | `scale(${state.scale[0]}, ${state.scale[1]}) ` + 34 | // Fix a bug in Chrome where widgets were dissapearing 35 | "translateZ(0px)"; 36 | } 37 | 38 | public static cssToTransform (css: string): Transform { 39 | const parsed: Record = {}; 40 | const regex = /([a-z]+)\(([^)]+)\)/ug; 41 | for (;;) { 42 | const result = regex.exec(css); 43 | if (!result) { 44 | break; 45 | } 46 | const numbers = result[2].split(",").map((str) => parseFloat(str.trim())); 47 | parsed[result[1]] = numbers; 48 | } 49 | return { 50 | rotate: parsed.rotate[0], 51 | scale: [ 52 | parsed.scale[0], 53 | parsed.scale[1] || parsed.scale[0] 54 | ], 55 | translate: [ 56 | parsed.translate[0], 57 | parsed.translate[1] || 0 58 | ] 59 | }; 60 | } 61 | 62 | public static setTransform (element: HTMLElement, state: Transform) { 63 | element.style.transform = Utility.transformToCss(state); 64 | } 65 | 66 | public static getTransform (element: HTMLElement): Transform { 67 | return Utility.cssToTransform(element.style.transform); 68 | } 69 | 70 | public static centerTransform (size: Size): Transform { 71 | return { 72 | rotate: 0, 73 | scale: [ 74 | 1, 75 | 1 76 | ], 77 | translate: [ 78 | size[0] / 2, 79 | size[1] / 2 80 | ] 81 | }; 82 | } 83 | } 84 | 85 | export type Size = [number, number]; 86 | 87 | export const getAspect = (size: Size) => size[0] / size[1]; 88 | 89 | export const resizeMinimumKeepAspect = (current: Size, target: Size): Size => { 90 | if (getAspect(current) > getAspect(target)) { 91 | return [ 92 | target[0], 93 | target[0] / current[0] * current[1] 94 | ]; 95 | } 96 | return [ 97 | target[1] / current[1] * current[0], 98 | target[1] 99 | ]; 100 | }; 101 | 102 | export type TimeRange = [number, number]; 103 | -------------------------------------------------------------------------------- /frontend/src/editor/videoEncoder.ts: -------------------------------------------------------------------------------- 1 | import {VideoPlayer} from "./videoPlayer"; 2 | 3 | export type VideoEncoderProgressCallback = (progress: number) => void; 4 | 5 | export interface VideoEncoder { 6 | initialize( 7 | canvas: HTMLCanvasElement, 8 | context: CanvasRenderingContext2D, 9 | video: VideoPlayer, 10 | onEncoderProgress: VideoEncoderProgressCallback): Promise; 11 | stop(): Promise; 12 | processFrame(): Promise; 13 | getOutputVideo(): Promise; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/editor/videoEncoderBrowser.ts: -------------------------------------------------------------------------------- 1 | import {Deferred} from "../shared/shared"; 2 | import {FRAME_RATE} from "./utility"; 3 | import {VideoEncoder} from "./videoEncoder"; 4 | 5 | export class VideoEncoderBrowser implements VideoEncoder { 6 | private stream: MediaStream; 7 | 8 | private recorder: MediaRecorder; 9 | 10 | private chunks: Blob[] = []; 11 | 12 | private gotLastData: Deferred; 13 | 14 | public async initialize (canvas: HTMLCanvasElement) { 15 | this.stream = (canvas as any).captureStream(FRAME_RATE) as MediaStream; 16 | this.recorder = new MediaRecorder(this.stream); 17 | this.recorder.ondataavailable = (event) => { 18 | this.chunks.push(event.data); 19 | if (this.gotLastData) { 20 | this.gotLastData.resolve(); 21 | } 22 | }; 23 | this.recorder.start(); 24 | } 25 | 26 | public async stop () { 27 | this.recorder.stop(); 28 | } 29 | 30 | public async processFrame () { 31 | const [videoTrack] = this.stream.getVideoTracks(); 32 | (videoTrack as any).requestFrame(); 33 | } 34 | 35 | public async getOutputVideo () { 36 | this.gotLastData = new Deferred(); 37 | this.stop(); 38 | await this.gotLastData; 39 | const output = new Blob(this.chunks, {type: this.recorder.mimeType}); 40 | this.chunks.length = 0; 41 | return output; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/editor/videoEncoderGif.ts: -------------------------------------------------------------------------------- 1 | import {Deferred} from "../shared/shared"; 2 | import {FRAME_RATE} from "./utility"; 3 | import GifEncoder from "gif-encoder"; 4 | import {VideoEncoder} from "./videoEncoder"; 5 | 6 | export class VideoEncoderGif implements VideoEncoder { 7 | private context: CanvasRenderingContext2D; 8 | 9 | private canvas: HTMLCanvasElement; 10 | 11 | private encoder: GifEncoder; 12 | 13 | private chunks: Uint8Array[] = []; 14 | 15 | public async initialize (canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) { 16 | this.canvas = canvas; 17 | this.context = context; 18 | this.encoder = new GifEncoder(canvas.width, canvas.height); 19 | this.encoder.setFrameRate(FRAME_RATE); 20 | this.encoder.on("data", (data) => { 21 | this.chunks.push(data); 22 | }); 23 | this.encoder.writeHeader(); 24 | } 25 | 26 | public async stop () { 27 | this.encoder = null; 28 | } 29 | 30 | public async processFrame () { 31 | const {data} = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height); 32 | this.encoder.addFrame(data); 33 | } 34 | 35 | public async getOutputVideo (): Promise { 36 | const deferred = new Deferred(); 37 | this.encoder.once("end", () => { 38 | deferred.resolve(); 39 | }); 40 | this.encoder.finish(); 41 | await deferred; 42 | return new Blob(this.chunks, {type: "image/gif"}); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/editor/videoEncoderH264MP4.ts: -------------------------------------------------------------------------------- 1 | import {FRAME_RATE} from "./utility"; 2 | import {VideoEncoder} from "./videoEncoder"; 3 | 4 | const makeEven = (value: number) => value - value % 2; 5 | 6 | export class VideoEncoderH264MP4 implements VideoEncoder { 7 | private context: CanvasRenderingContext2D; 8 | 9 | private encoder: import("h264-mp4-encoder").H264MP4Encoder; 10 | 11 | public async initialize (canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) { 12 | const HME = await import("h264-mp4-encoder"); 13 | this.context = context; 14 | 15 | this.encoder = await HME.createH264MP4Encoder(); 16 | this.encoder.frameRate = FRAME_RATE; 17 | this.encoder.width = makeEven(canvas.width); 18 | this.encoder.height = makeEven(canvas.height); 19 | 20 | this.encoder.initialize(); 21 | } 22 | 23 | public async stop () { 24 | if (this.encoder) { 25 | this.encoder.finalize(); 26 | this.encoder.delete(); 27 | this.encoder = null; 28 | } 29 | } 30 | 31 | public async processFrame () { 32 | const {data} = this.context.getImageData(0, 0, this.encoder.width, this.encoder.height); 33 | this.encoder.addFrameRgba(data); 34 | } 35 | 36 | public async getOutputVideo (): Promise { 37 | this.encoder.finalize(); 38 | const buffer = this.encoder.FS.readFile(this.encoder.outputFilename); 39 | this.encoder.FS.unlink(this.encoder.outputFilename); 40 | this.encoder.delete(); 41 | this.encoder = null; 42 | return new Blob([buffer], {type: "video/mp4"}); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/editor/videoEncoderWebm.ts: -------------------------------------------------------------------------------- 1 | import {FRAME_RATE} from "./utility"; 2 | import {VideoEncoder} from "./videoEncoder"; 3 | import Whammy from "whammy"; 4 | 5 | export class VideoEncoderWebm implements VideoEncoder { 6 | private canvas: HTMLCanvasElement; 7 | 8 | private encoder: Whammy.Video; 9 | 10 | public async initialize (canvas: HTMLCanvasElement) { 11 | this.canvas = canvas; 12 | this.encoder = new Whammy.Video(FRAME_RATE); 13 | } 14 | 15 | public async stop () { 16 | this.encoder = null; 17 | } 18 | 19 | public async processFrame () { 20 | this.encoder.add(this.canvas); 21 | } 22 | 23 | public async getOutputVideo (): Promise { 24 | return this.encoder.compile(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/editor/videoPlayer.css: -------------------------------------------------------------------------------- 1 | .videoPlayer { 2 | background-size: cover; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | .videoControlsContainer { 8 | position: absolute; 9 | height: 10vmin; 10 | bottom: 0px; 11 | left: 0px; 12 | right: 0px; 13 | opacity: 0.7; 14 | /* Fix a bug in Chrome with the video drawing over sometimes */ 15 | transform: translateZ(0); 16 | z-index: 1; 17 | } 18 | 19 | .videoPlayPauseButton { 20 | position: absolute; 21 | left: 0px; 22 | height: 100%; 23 | width: 15vmin; 24 | font-size: 9vmin; 25 | text-align: center; 26 | transform: scale(0.9); 27 | } 28 | 29 | .videoTimeline { 30 | background-color: black; 31 | position: absolute; 32 | right: 0px; 33 | height: 100%; 34 | width: calc(100% - 15vmin); 35 | } 36 | 37 | .videoSelection { 38 | background-color: #1f61ff; 39 | position: absolute; 40 | right: 0px; 41 | top: 0px; 42 | height: 8%; 43 | z-index: 1; 44 | } 45 | 46 | .videoPosition { 47 | background-color: gray; 48 | position: absolute; 49 | left: 0px; 50 | height: 100%; 51 | } 52 | 53 | .videoMarker { 54 | position: absolute; 55 | width: 3px; 56 | transform: translateX(-1px); 57 | height: 100%; 58 | } 59 | -------------------------------------------------------------------------------- /frontend/src/editor/videoPlayer.ts: -------------------------------------------------------------------------------- 1 | import "./videoPlayer.css"; 2 | import {AttributedSource, MAX_VIDEO_SIZE} from "../../../common/common"; 3 | import {RELATIVE_VIDEO_SIZE, Size, TimeRange, UPDATE, resizeMinimumKeepAspect} from "./utility"; 4 | import {Deferred} from "../shared/shared"; 5 | import {theme} from "../page/style"; 6 | 7 | interface Point { 8 | clientX: number; 9 | clientY: number; 10 | } 11 | 12 | const MARKER_NORMALIZED_TIME = "time"; 13 | 14 | export class VideoPlayer extends EventTarget { 15 | public readonly video: HTMLVideoElement; 16 | 17 | private controlsContainer: HTMLDivElement; 18 | 19 | private playPauseButton: HTMLDivElement; 20 | 21 | private position: HTMLDivElement; 22 | 23 | private timeline: HTMLDivElement; 24 | 25 | private selection: HTMLDivElement; 26 | 27 | private readonly markers: HTMLDivElement[] = []; 28 | 29 | public loadPromise = new Deferred(); 30 | 31 | public selectionStartNormalized = 0; 32 | 33 | public selectionEndNormalized = 0; 34 | 35 | public constructor (videoParent: HTMLDivElement, controlsParent: HTMLElement) { 36 | super(); 37 | this.video = document.createElement("video"); 38 | videoParent.appendChild(this.video); 39 | this.video.className = "videoPlayer"; 40 | this.video.crossOrigin = "anonymous"; 41 | this.video.muted = true; 42 | this.video.preload = "auto"; 43 | 44 | this.video.setAttribute("webkit-playsinline", "true"); 45 | this.video.setAttribute("playsinline", "true"); 46 | (this.video as any).playsInline = true; 47 | (this.video as any).playsinline = true; 48 | 49 | (this.video as any).disableRemotePlayback = true; 50 | this.video.oncontextmenu = () => false; 51 | 52 | this.controlsContainer = document.createElement("div"); 53 | this.controlsContainer.className = "videoControlsContainer"; 54 | controlsParent.appendChild(this.controlsContainer); 55 | 56 | this.playPauseButton = document.createElement("div"); 57 | this.controlsContainer.appendChild(this.playPauseButton); 58 | this.playPauseButton.className = "videoPlayPauseButton button fas fa-play"; 59 | 60 | this.video.addEventListener("play", () => { 61 | this.playPauseButton.classList.remove("fa-play"); 62 | this.playPauseButton.classList.add("fa-pause"); 63 | this.video.loop = true; 64 | }); 65 | this.video.addEventListener("pause", () => { 66 | this.playPauseButton.classList.remove("fa-pause"); 67 | this.playPauseButton.classList.add("fa-play"); 68 | this.video.loop = false; 69 | }); 70 | this.playPauseButton.addEventListener("click", () => { 71 | if (this.video.paused) { 72 | this.video.play().catch(() => 0); 73 | } else { 74 | this.video.pause(); 75 | } 76 | }); 77 | 78 | this.timeline = document.createElement("div"); 79 | this.controlsContainer.appendChild(this.timeline); 80 | this.timeline.className = "videoTimeline"; 81 | 82 | this.selection = document.createElement("div"); 83 | this.timeline.appendChild(this.selection); 84 | this.selection.className = "videoSelection"; 85 | 86 | this.position = document.createElement("div"); 87 | this.timeline.appendChild(this.position); 88 | this.position.className = "videoPosition"; 89 | 90 | const visibleUpdatePosition = () => { 91 | const interpolant = this.video.currentTime / this.video.duration; 92 | this.position.style.width = `${interpolant * 100}%`; 93 | }; 94 | window.addEventListener(UPDATE, visibleUpdatePosition); 95 | 96 | const updateTimelineFromPoint = (event: Point, start: boolean) => { 97 | const rect = this.timeline.getBoundingClientRect(); 98 | const left = event.clientX - rect.left; 99 | const interpolant = Math.max(Math.min(left / rect.width, 0.9999), 0); 100 | this.video.currentTime = this.video.duration * interpolant; 101 | visibleUpdatePosition(); 102 | if (start) { 103 | this.selectionStartNormalized = interpolant; 104 | } 105 | this.selectionEndNormalized = interpolant; 106 | 107 | const selectionRange = this.getSelectionRangeInOrder(); 108 | this.selection.style.left = `${selectionRange[0] * 100}%`; 109 | this.selection.style.right = `${(1 - selectionRange[1]) * 100}%`; 110 | 111 | this.updateMarkerHighlights(); 112 | }; 113 | 114 | const onTouchMove = (event: TouchEvent) => { 115 | updateTimelineFromPoint(event.touches[0], event.type === "touchstart"); 116 | }; 117 | this.timeline.addEventListener("touchstart", (event) => { 118 | this.timeline.addEventListener("touchmove", onTouchMove); 119 | onTouchMove(event); 120 | }); 121 | this.timeline.addEventListener("touchend", () => { 122 | this.timeline.removeEventListener("touchmove", onTouchMove); 123 | }); 124 | 125 | const onPointerMove = (event: PointerEvent) => { 126 | updateTimelineFromPoint(event, event.type === "pointerdown"); 127 | }; 128 | this.timeline.addEventListener("pointerdown", (event) => { 129 | this.timeline.setPointerCapture(event.pointerId); 130 | this.timeline.addEventListener("pointermove", onPointerMove); 131 | onPointerMove(event); 132 | }); 133 | this.timeline.addEventListener("pointerup", (event) => { 134 | this.timeline.releasePointerCapture(event.pointerId); 135 | this.timeline.removeEventListener("pointermove", onPointerMove); 136 | }); 137 | 138 | this.video.addEventListener("canplaythrough", () => { 139 | this.loadPromise.resolve(); 140 | // Other libraries such as OpenCV.js rely on video.width/height being set. 141 | this.video.width = this.video.videoWidth; 142 | this.video.height = this.video.videoHeight; 143 | }); 144 | } 145 | 146 | public getSelectionRangeInOrder (): TimeRange { 147 | if (this.selectionStartNormalized > this.selectionEndNormalized) { 148 | return [ 149 | this.selectionEndNormalized, 150 | this.selectionStartNormalized 151 | ]; 152 | } 153 | return [ 154 | this.selectionStartNormalized, 155 | this.selectionEndNormalized 156 | ]; 157 | } 158 | 159 | public async setAttributedSrc (attributedSource: AttributedSource) { 160 | this.loadPromise = new Deferred(); 161 | // Workers static doesn't support Accept-Ranges, so we just preload the entire video. 162 | const response = await fetch(attributedSource.src); 163 | const blob = await response.blob(); 164 | this.video.src = URL.createObjectURL(blob); 165 | this.video.dataset.src = attributedSource.src; 166 | this.video.dataset.attributionJson = JSON.stringify(attributedSource); 167 | await this.loadPromise; 168 | this.dispatchEvent(new Event("srcChanged")); 169 | } 170 | 171 | public getAttributedSrc (): AttributedSource { 172 | return JSON.parse(this.video.dataset.attributionJson); 173 | } 174 | 175 | public updateMarkerHighlights () { 176 | const selectionRange = this.getSelectionRangeInOrder(); 177 | for (const marker of this.markers) { 178 | const normalizedMarkerTime = parseFloat(marker.dataset[MARKER_NORMALIZED_TIME]); 179 | if (normalizedMarkerTime >= selectionRange[0] && normalizedMarkerTime <= selectionRange[1]) { 180 | marker.style.backgroundColor = theme.palette.secondary.main; 181 | } else { 182 | marker.style.backgroundColor = theme.palette.primary.dark; 183 | } 184 | } 185 | } 186 | 187 | public setMarkers (normalizedMarkerTimes: number[]) { 188 | for (const marker of this.markers) { 189 | marker.remove(); 190 | } 191 | this.markers.length = 0; 192 | 193 | for (const normalizedMarkerTime of normalizedMarkerTimes) { 194 | const marker = document.createElement("div"); 195 | this.timeline.appendChild(marker); 196 | marker.className = "videoMarker"; 197 | marker.style.left = `${normalizedMarkerTime * 100}%`; 198 | marker.dataset[MARKER_NORMALIZED_TIME] = String(normalizedMarkerTime); 199 | this.markers.push(marker); 200 | } 201 | 202 | this.updateMarkerHighlights(); 203 | } 204 | 205 | public getRawSize (): Size { 206 | return [ 207 | this.video.videoWidth || MAX_VIDEO_SIZE, 208 | this.video.videoHeight || MAX_VIDEO_SIZE 209 | ]; 210 | } 211 | 212 | public getAspectSize () { 213 | return resizeMinimumKeepAspect(this.getRawSize(), [RELATIVE_VIDEO_SIZE, RELATIVE_VIDEO_SIZE]); 214 | } 215 | 216 | public getNormalizedCurrentTime () { 217 | return this.video.currentTime / (this.video.duration || 1); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /frontend/src/editor/videoSeeker.ts: -------------------------------------------------------------------------------- 1 | import {Deferred} from "../shared/shared"; 2 | import {FRAME_TIME} from "./utility"; 3 | import {VideoPlayer} from "./videoPlayer"; 4 | 5 | export class VideoSeekerFrame { 6 | public normalizedCurrentTime: number; 7 | 8 | public currentTime: number; 9 | 10 | public progress: number; 11 | } 12 | 13 | export abstract class VideoSeeker { 14 | public readonly player: VideoPlayer; 15 | 16 | private runningPromise: Deferred = null; 17 | 18 | private isStopped = false; 19 | 20 | public constructor (player: VideoPlayer) { 21 | this.player = player; 22 | } 23 | 24 | public snapToFrameRate (time: number) { 25 | return Math.round(time / FRAME_TIME) * FRAME_TIME; 26 | } 27 | 28 | protected async run (startTime: number): Promise { 29 | this.runningPromise = new Deferred(); 30 | await this.player.loadPromise; 31 | const {video} = this.player; 32 | video.pause(); 33 | 34 | const frame = new VideoSeekerFrame(); 35 | frame.currentTime = this.snapToFrameRate(startTime); 36 | frame.normalizedCurrentTime = frame.currentTime / video.duration; 37 | 38 | const onSeek = async () => { 39 | if (this.isStopped) { 40 | this.runningPromise.resolve(false); 41 | } 42 | frame.progress = frame.currentTime / video.duration; 43 | await this.onFrame(frame); 44 | 45 | // It's possible that while awaiting we got cancelled and the running promise was removed. 46 | if (!this.runningPromise) { 47 | return; 48 | } 49 | 50 | if (frame.currentTime + FRAME_TIME > video.duration) { 51 | this.runningPromise.resolve(true); 52 | } 53 | frame.currentTime = this.snapToFrameRate(frame.currentTime + FRAME_TIME); 54 | frame.normalizedCurrentTime = frame.currentTime / video.duration; 55 | 56 | video.currentTime = frame.currentTime; 57 | }; 58 | 59 | video.addEventListener("seeked", onSeek); 60 | video.currentTime = frame.currentTime; 61 | 62 | const result = await this.runningPromise; 63 | 64 | video.removeEventListener("seeked", onSeek); 65 | 66 | this.runningPromise = null; 67 | this.isStopped = false; 68 | return result; 69 | } 70 | 71 | protected abstract async onFrame (frame: VideoSeekerFrame); 72 | 73 | public async stop () { 74 | if (this.runningPromise) { 75 | this.isStopped = true; 76 | await this.runningPromise; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/src/index.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 | 11 | 12 | 13 | 15 | 34 | <%= require('./tracking.html').default %> 35 | 36 | 37 | 38 |
39 |
40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./shared/firebase"; 2 | import "./page/fonts.css"; 3 | import "./page/hashScroll"; 4 | import {API_ALL_THREADS_ID, API_REMIXED_THREADS_ID, API_TRENDING_THREADS_ID} from "../../common/common"; 5 | import { 6 | BrowserRouter, 7 | Route, 8 | Link as RouterLink, 9 | Switch 10 | } from "react-router-dom"; 11 | import { 12 | Deferred, 13 | EVENT_MENU_OPEN, 14 | EVENT_REQUEST_LOGIN, 15 | NonAlertingError, 16 | RequestLoginEvent, 17 | isDevEnvironment, 18 | signInIfNeeded, 19 | signOut 20 | } from "./shared/shared"; 21 | import {LoginDialog, LoginUserIdState} from "./page/login"; 22 | import {theme, useStyles} from "./page/style"; 23 | import AccountBoxIcon from "@material-ui/icons/AccountBox"; 24 | import AppBar from "@material-ui/core/AppBar"; 25 | import Box from "@material-ui/core/Box"; 26 | import Button from "@material-ui/core/Button"; 27 | import CssBaseline from "@material-ui/core/CssBaseline"; 28 | import Drawer from "@material-ui/core/Drawer"; 29 | import GitHubIcon from "@material-ui/icons/GitHub"; 30 | import HomeIcon from "@material-ui/icons/Home"; 31 | import IconButton from "@material-ui/core/IconButton"; 32 | import {IndeterminateProgress} from "./page/progress"; 33 | import Link from "@material-ui/core/Link"; 34 | import List from "@material-ui/core/List"; 35 | import ListItem from "@material-ui/core/ListItem"; 36 | import ListItemIcon from "@material-ui/core/ListItemIcon"; 37 | import ListItemText from "@material-ui/core/ListItemText"; 38 | import MenuIcon from "@material-ui/icons/Menu"; 39 | import {ModalContainer} from "./editor/modal"; 40 | import MovieIcon from "@material-ui/icons/Movie"; 41 | import PersonIcon from "@material-ui/icons/Person"; 42 | import {Profile} from "./page/profile"; 43 | import React from "react"; 44 | import ReactDOM from "react-dom"; 45 | import StorageIcon from "@material-ui/icons/Storage"; 46 | import {ThemeProvider} from "@material-ui/core/styles"; 47 | import {Thread} from "./page/thread"; 48 | import Toolbar from "@material-ui/core/Toolbar"; 49 | import Typography from "@material-ui/core/Typography"; 50 | import {UnsavedChangesPrompt} from "./shared/unload"; 51 | import firebase from "firebase/app"; 52 | 53 | const EditorComponent = React.lazy(() => import("./editor/editorComponent")); 54 | 55 | const getUrlParam = (props: { location: import("history").Location }, name: string) => 56 | JSON.parse(new URLSearchParams(props.location.search).get(name)); 57 | 58 | const App = () => { 59 | const [showLoginDeferred, setShowLoginDeferred] = React.useState | null>(null); 60 | const [loggedInUserId, setLoggedInUserId] = React.useState(undefined); 61 | const [menuOpen, setMenuOpen] = React.useState(false); 62 | 63 | window.addEventListener(EVENT_MENU_OPEN, () => setMenuOpen(true)); 64 | 65 | const closeMenuCallback = () => setMenuOpen(false); 66 | 67 | React.useEffect(() => { 68 | firebase.auth().onAuthStateChanged((user) => { 69 | if (user) { 70 | setLoggedInUserId(user.uid); 71 | } else { 72 | setLoggedInUserId(null); 73 | } 74 | }); 75 | 76 | const onRequestLogin = (event: RequestLoginEvent) => { 77 | setShowLoginDeferred(event.deferredLoginPicked); 78 | }; 79 | window.addEventListener(EVENT_REQUEST_LOGIN, onRequestLogin); 80 | return () => { 81 | window.removeEventListener(EVENT_REQUEST_LOGIN, onRequestLogin); 82 | }; 83 | }, []); 84 | 85 | const emulatorUi = new URL(new URL(window.location.href).origin); 86 | emulatorUi.port = "5001"; 87 | 88 | const classes = useStyles(); 89 | return 90 | 91 | 92 | 93 | 95 | }> 96 | 97 | 98 | } 99 | /> 100 | 101 |
102 | 103 | 104 | setMenuOpen(true)}> 109 | 110 | 111 | 112 | 113 | 118 | 119 | 120 | 121 | 122 | {require("../title")} 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 |
131 |
132 | 133 | 135 |
136 | 137 | TRENDING Posts 138 | 139 | 144 | 145 | NEWEST Posts 146 | 147 | 152 | 153 | REMIXED Posts 154 | 155 | 160 |
} 161 | /> 162 | { 164 | const threadId = getUrlParam(prop, "threadId"); 165 | return ; 170 | }} 171 | /> 172 | } 174 | /> 175 |
176 |
177 |
178 |
179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | { 194 | loggedInUserId 195 | ? <> 196 | 197 | 198 | 199 | 200 | 201 | 202 | signOut()}> 203 | 204 | 205 | 206 | 207 | : signInIfNeeded().catch(() => 0)}> 208 | 209 | 210 | 211 | } 212 | 218 | 219 | 220 | 221 | 222 | 223 | { 224 | isDevEnvironment() 225 | ? 231 | 232 | 233 | 234 | 235 | 236 | : null 237 | } 238 | 239 | 240 |
241 | 242 | { 245 | showLoginDeferred.reject(new NonAlertingError("The login was cancelled")); 246 | setShowLoginDeferred(null); 247 | }} 248 | onSignInFailure={(message) => { 249 | showLoginDeferred.reject(new Error(message)); 250 | setShowLoginDeferred(null); 251 | }} 252 | onSignInSuccess={(uid: string) => { 253 | setLoggedInUserId(uid); 254 | showLoginDeferred.resolve(); 255 | setShowLoginDeferred(null); 256 | }}/> 257 |
; 258 | }; 259 | 260 | ReactDOM.render(, document.getElementById("root")); 261 | -------------------------------------------------------------------------------- /frontend/src/page/animationVideo.tsx: -------------------------------------------------------------------------------- 1 | import {API_ANIMATION_VIDEO} from "../../../common/common"; 2 | import React from "react"; 3 | import {makeServerUrl} from "../shared/shared"; 4 | import {useStyles} from "./style"; 5 | 6 | export interface AnimationVideoProps extends 7 | React.DetailedHTMLProps, HTMLVideoElement> { 8 | id: string; 9 | width: number; 10 | height: number; 11 | } 12 | 13 | export const AnimationVideo: React.FC = (props) => { 14 | const classes = useStyles(); 15 | return
24 | 38 |
; 39 | }; 40 | -------------------------------------------------------------------------------- /frontend/src/page/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Arvo; 3 | src: url('../public/arvo.ttf'); 4 | } -------------------------------------------------------------------------------- /frontend/src/page/hashScroll.tsx: -------------------------------------------------------------------------------- 1 | let lastHash = ""; 2 | 3 | const onHashChange = () => { 4 | const savedHash = location.hash; 5 | if (!savedHash) { 6 | lastHash = savedHash; 7 | return; 8 | } 9 | const element = document.getElementById(savedHash.slice(1)); 10 | if (!element) { 11 | return; 12 | } 13 | 14 | lastHash = savedHash; 15 | 16 | console.log("Begin scrolling to dynamic element", savedHash); 17 | setTimeout(() => { 18 | element.scrollIntoView(); 19 | console.log("End scrolling to dynamic element", savedHash); 20 | }, 200); 21 | }; 22 | 23 | setInterval(() => { 24 | if (location.hash !== lastHash) { 25 | onHashChange(); 26 | } 27 | }, 200); 28 | -------------------------------------------------------------------------------- /frontend/src/page/likeButton.tsx: -------------------------------------------------------------------------------- 1 | import {API_POST_LIKE, ClientPost} from "../../../common/common"; 2 | import {Auth, abortableJsonFetch} from "../shared/shared"; 3 | import Badge from "@material-ui/core/Badge"; 4 | import FavoriteIcon from "@material-ui/icons/Favorite"; 5 | import IconButton from "@material-ui/core/IconButton"; 6 | import React from "react"; 7 | 8 | interface LikeButtonProps { 9 | post: ClientPost; 10 | } 11 | 12 | export const LikeButton: React.FC = (props) => { 13 | const [liked, setLiked] = React.useState(props.post.liked); 14 | const [likes, setLikes] = React.useState(props.post.likes); 15 | 16 | // Since we create the psuedo post to start with, the like staet can change from props.post. 17 | React.useEffect(() => { 18 | setLiked(props.post.liked); 19 | }, [props.post.liked]); 20 | React.useEffect(() => { 21 | setLikes(props.post.likes); 22 | }, [props.post.likes]); 23 | 24 | return { 27 | e.stopPropagation(); 28 | const newLiked = !liked; 29 | const postLike = 30 | await abortableJsonFetch(API_POST_LIKE, Auth.Required, {id: props.post.id, liked: newLiked}); 31 | setLiked(newLiked); 32 | setLikes(postLike.likes); 33 | }}> 34 | 35 | 36 | 37 | ; 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/page/login.tsx: -------------------------------------------------------------------------------- 1 | import Dialog from "@material-ui/core/Dialog"; 2 | import {ModalProps} from "@material-ui/core/Modal"; 3 | import React from "react"; 4 | import StyledFirebaseAuth from "react-firebaseui/StyledFirebaseAuth"; 5 | import firebase from "firebase/app"; 6 | 7 | export type LoginUserIdState = undefined | string | null; 8 | 9 | export interface LoginDialogProps { 10 | open: boolean; 11 | onClose: ModalProps["onClose"]; 12 | onSignInFailure: (message: string) => any; 13 | onSignInSuccess: (uid: string) => any; 14 | } 15 | 16 | export const LoginDialog: React.FC = (props) => 19 | props.onSignInFailure(error.message), 23 | signInSuccessWithAuthResult: (result) => { 24 | props.onSignInSuccess(result.user.uid); 25 | return false; 26 | } 27 | }, 28 | signInOptions: [ 29 | firebase.auth.EmailAuthProvider.PROVIDER_ID, 30 | firebase.auth.GoogleAuthProvider.PROVIDER_ID, 31 | firebase.auth.FacebookAuthProvider.PROVIDER_ID 32 | ] 33 | }} firebaseAuth={firebase.auth()}/> 34 | ; 35 | -------------------------------------------------------------------------------- /frontend/src/page/post.tsx: -------------------------------------------------------------------------------- 1 | import * as timeago from "timeago.js"; 2 | import {makeFullLocalUrl, makeLocalUrl} from "../shared/shared"; 3 | import {AnimationVideo} from "./animationVideo"; 4 | import Box from "@material-ui/core/Box"; 5 | import Button from "@material-ui/core/Button"; 6 | import Card from "@material-ui/core/Card"; 7 | import CardActions from "@material-ui/core/CardActions"; 8 | import CardContent from "@material-ui/core/CardContent"; 9 | import CardHeader from "@material-ui/core/CardHeader"; 10 | import CardMedia from "@material-ui/core/CardMedia"; 11 | import {ClientPost} from "../../../common/common"; 12 | import {LikeButton} from "./likeButton"; 13 | import Link from "@material-ui/core/Link"; 14 | import MenuItem from "@material-ui/core/MenuItem"; 15 | import React from "react"; 16 | import Select from "@material-ui/core/Select"; 17 | import {ShareButton} from "./shareButton"; 18 | import {TrashButton} from "./trashButton"; 19 | import Typography from "@material-ui/core/Typography"; 20 | import {UserAvatar} from "./userAvatar"; 21 | import millify from "millify"; 22 | import pluralize from "pluralize"; 23 | import {useStyles} from "./style"; 24 | 25 | interface PostProps { 26 | post: ClientPost; 27 | preview: boolean; 28 | cardStyle?: React.CSSProperties; 29 | onClick?: React.MouseEventHandler; 30 | videoProps?: React.DetailedHTMLProps, HTMLVideoElement>; 31 | history: import("history").History; 32 | onTrashed?: () => void; 33 | } 34 | 35 | export const Post: React.FC = (props) => { 36 | const classes = useStyles(); 37 | return { 42 | // Prevent the share Popover from triggering us on close. 43 | let element = e.target instanceof HTMLElement ? e.target : null; 44 | while (element) { 45 | if (element.getAttribute("data-ignore-click") === "true") { 46 | return; 47 | } 48 | element = element.parentElement; 49 | } 50 | 51 | if (props.onClick) { 52 | props.onClick(e); 53 | } 54 | }}> 55 | 62 | } 63 | action={ 64 |
65 | { 66 | !props.preview && props.post.canDelete 67 | ? 68 | 69 | 70 | : null 71 | } 72 | 73 |
74 | } 75 | title={<> 76 | 80 | {props.post.username} 81 | {props.post.type === "remix" && !props.preview 82 | ? 83 | Remix of... 87 | 88 | : null} 89 | 90 | {props.preview 91 | ? null 92 | : 95 | {timeago.format(props.post.dateMsSinceEpoch)} 96 | } 97 | } 98 | subheader={props.post.userdata.type === "animation" ? props.post.title : props.post.message} 99 | /> 100 | { 101 | props.post.userdata.type === "animation" 102 | ? 103 | 109 | 110 | : null 111 | } 112 | { 113 | props.post.userdata.type === "animation" 114 | ?
115 | 116 | 117 | {props.post.message} 118 | 119 | 120 | 121 | 130 | { 131 | props.post.type === "thread" || props.post.type === "remix" 132 | ? 133 | 134 | {props.preview 135 | ? millify(props.post.views, {precision: 1}) 136 | : props.post.views.toLocaleString()} 137 | {` ${pluralize("view", props.post.views)}`} 138 | 139 | 140 | : null 141 | } 142 |
143 | { 144 | props.preview 145 | ? null 146 | : 147 | 173 | 174 | } 175 | 182 |
183 |
184 | : null 185 | } 186 |
; 187 | }; 188 | -------------------------------------------------------------------------------- /frontend/src/page/profile.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | API_PROFILE_AVATAR_UPDATE, 3 | API_PROFILE_UPDATE, 4 | COLLECTION_USERS, 5 | StoredUser 6 | } from "../../../common/common"; 7 | import {AbortablePromise, Auth, abortable, abortableJsonFetch, cancel} from "../shared/shared"; 8 | import Box from "@material-ui/core/Box"; 9 | import Button from "@material-ui/core/Button"; 10 | import Divider from "@material-ui/core/Divider"; 11 | import {IndeterminateProgress} from "./progress"; 12 | import {LoginUserIdState} from "./login"; 13 | import React from "react"; 14 | import {SubmitButton} from "./submitButton"; 15 | import TextField from "@material-ui/core/TextField"; 16 | import {UserAvatar} from "./userAvatar"; 17 | import {store} from "../shared/firebase"; 18 | 19 | export interface ProfileProps { 20 | loggedInUserId: LoginUserIdState; 21 | } 22 | 23 | export const Profile: React.FC = (props) => { 24 | const [user, setUser] = React.useState(null); 25 | const [profileUpdateFetch, setProfileUpdateFetch] = React.useState>(null); 26 | const [userAvatar, setUserAvatar] = React.useState(null); 27 | 28 | React.useEffect(() => { 29 | if (props.loggedInUserId) { 30 | const profilePromise = abortable(store.collection(COLLECTION_USERS).doc(props.loggedInUserId).get()); 31 | (async () => { 32 | const profileDoc = await profilePromise; 33 | if (profileDoc) { 34 | setUser(profileDoc.data() as StoredUser); 35 | } 36 | })(); 37 | return () => { 38 | cancel(profilePromise); 39 | }; 40 | } 41 | return () => 0; 42 | }, [props.loggedInUserId]); 43 | 44 | React.useEffect(() => () => { 45 | cancel(profileUpdateFetch); 46 | }, []); 47 | 48 | React.useEffect(() => { 49 | if (userAvatar) { 50 | const avatarCreatePromise = abortableJsonFetch( 51 | API_PROFILE_AVATAR_UPDATE, 52 | Auth.Required, 53 | {}, 54 | userAvatar 55 | ); 56 | (async () => { 57 | const updatedUser = await avatarCreatePromise; 58 | if (updatedUser) { 59 | setUser(updatedUser); 60 | } 61 | })(); 62 | return () => cancel(avatarCreatePromise); 63 | } 64 | return () => 0; 65 | }, [userAvatar]); 66 | 67 | if (!user) { 68 | return ; 69 | } 70 | const {minLength, maxLength} = API_PROFILE_UPDATE.props.username; 71 | return ( 72 |
73 | 74 | 75 | 79 | 80 | 94 | 95 | 96 | 97 |
{ 99 | e.preventDefault(); 100 | const profileUpdateFetchPromise = abortableJsonFetch( 101 | API_PROFILE_UPDATE, 102 | Auth.Required, 103 | { 104 | bio: user.bio, 105 | username: user.username 106 | } 107 | ); 108 | setProfileUpdateFetch(profileUpdateFetchPromise); 109 | 110 | try { 111 | const updatedUser = await profileUpdateFetchPromise; 112 | if (updatedUser) { 113 | setUser(updatedUser); 114 | } 115 | } finally { 116 | setProfileUpdateFetch(null); 117 | } 118 | }}> 119 | { 132 | setUser({...user, username: e.target.value}); 133 | }}/> 134 | { 141 | setUser({...user, bio: e.target.value}); 142 | }}/> 143 | 144 | Update Profile 145 | 146 | 147 |
148 |
); 149 | }; 150 | -------------------------------------------------------------------------------- /frontend/src/page/progress.tsx: -------------------------------------------------------------------------------- 1 | import Box from "@material-ui/core/Box"; 2 | import CircularProgress from "@material-ui/core/CircularProgress"; 3 | import React from "react"; 4 | 5 | export const IndeterminateProgress: React.FC = () => 6 | 7 | 8 | ; 9 | -------------------------------------------------------------------------------- /frontend/src/page/shareButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | EmailIcon, 3 | EmailShareButton, 4 | FacebookIcon, 5 | FacebookShareButton, 6 | RedditIcon, 7 | RedditShareButton, 8 | TumblrIcon, 9 | TumblrShareButton, 10 | TwitterIcon, 11 | TwitterShareButton, 12 | WhatsappIcon, 13 | WhatsappShareButton 14 | } from "react-share"; 15 | import {constants, useStyles} from "./style"; 16 | import Button from "@material-ui/core/Button"; 17 | import Card from "@material-ui/core/Card"; 18 | import CardActions from "@material-ui/core/CardActions"; 19 | import CardContent from "@material-ui/core/CardContent"; 20 | import IconButton from "@material-ui/core/IconButton"; 21 | import Popover from "@material-ui/core/Popover"; 22 | import React from "react"; 23 | import ShareIcon from "@material-ui/icons/Share"; 24 | import TextField from "@material-ui/core/TextField"; 25 | import copy from "copy-to-clipboard"; 26 | 27 | interface ShareButtonProps { 28 | title: string; 29 | url: string; 30 | } 31 | 32 | export const ShareButton: React.FC = (props) => { 33 | const [anchorElement, setAnchorElement] = React.useState(null); 34 | const [copied, setCopied] = React.useState(false); 35 | 36 | const classes = useStyles(); 37 | return
38 | { 39 | e.stopPropagation(); 40 | setAnchorElement(e.currentTarget); 41 | }}> 42 | 43 | 44 | setAnchorElement(null)} 50 | anchorOrigin={{ 51 | vertical: "bottom", 52 | horizontal: "center" 53 | }} 54 | transformOrigin={{ 55 | vertical: "top", 56 | horizontal: "center" 57 | }} 58 | > 59 | 60 | 61 |
62 | e.target.setSelectionRange(0, Number.MAX_SAFE_INTEGER)} 65 | size="small" 66 | label="Link" 67 | defaultValue={props.url} 68 | InputProps={{ 69 | readOnly: true 70 | }} 71 | variant="outlined" 72 | /> 73 | 81 |
82 |
83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 |
117 |
118 |
; 119 | }; 120 | -------------------------------------------------------------------------------- /frontend/src/page/style.tsx: -------------------------------------------------------------------------------- 1 | import {Theme, createMuiTheme, createStyles, fade, makeStyles} from "@material-ui/core/styles"; 2 | 3 | export const PAGE_WIDTH = 960; 4 | 5 | export const useStyles = makeStyles((theme: Theme) => 6 | createStyles({ 7 | root: { 8 | flexGrow: 1 9 | }, 10 | pageWidth: { 11 | maxWidth: PAGE_WIDTH, 12 | paddingLeft: theme.spacing(), 13 | paddingRight: theme.spacing(), 14 | width: "100%", 15 | margin: "auto" 16 | }, 17 | toolbar: theme.mixins.toolbar, 18 | title: { 19 | flexGrow: 1, 20 | fontFamily: "'Arvo', serif" 21 | }, 22 | closeButton: { 23 | position: "absolute", 24 | right: theme.spacing(1), 25 | top: theme.spacing(1), 26 | color: theme.palette.grey[500] 27 | }, 28 | link: { 29 | color: "inherit", 30 | textDecoration: "inherit" 31 | }, 32 | video: { 33 | width: "100%", 34 | height: "auto" 35 | }, 36 | shareSocialButton: { 37 | flex: 1 38 | }, 39 | cardHeader: { 40 | display: "grid" 41 | }, 42 | masonryGrid: { 43 | display: "flex", 44 | marginLeft: -theme.spacing(), 45 | width: "auto" 46 | }, 47 | masonryGridColumn: { 48 | paddingLeft: theme.spacing(), 49 | backgroundClip: "padding-box" 50 | }, 51 | search: { 52 | "position": "relative", 53 | "borderRadius": theme.shape.borderRadius, 54 | "backgroundColor": fade(theme.palette.common.white, 0.15), 55 | "&:hover": { 56 | backgroundColor: fade(theme.palette.common.white, 0.25) 57 | }, 58 | "width": "100%" 59 | }, 60 | searchIcon: { 61 | padding: theme.spacing(0, 2), 62 | height: "100%", 63 | position: "absolute", 64 | pointerEvents: "none", 65 | display: "flex", 66 | alignItems: "center", 67 | justifyContent: "center" 68 | }, 69 | searchInputRoot: { 70 | color: "inherit", 71 | width: "100%" 72 | }, 73 | searchInputInput: { 74 | padding: theme.spacing(1, 1, 1, 0), 75 | // Vertical padding + font size from searchIcon 76 | paddingLeft: `calc(1em + ${theme.spacing(4)}px)`, 77 | width: "100%" 78 | }, 79 | searchImage: { 80 | marginTop: theme.spacing(), 81 | cursor: "pointer", 82 | width: "100%", 83 | verticalAlign: "top" 84 | }, 85 | username: { 86 | float: "left" 87 | }, 88 | postTime: { 89 | float: "right", 90 | marginRight: theme.spacing(), 91 | color: theme.palette.text.secondary 92 | } 93 | }), {index: 1}); 94 | 95 | export const theme = createMuiTheme({ 96 | palette: { 97 | type: "dark" 98 | } 99 | }); 100 | 101 | export const constants = { 102 | shareIconSize: 32 103 | }; 104 | -------------------------------------------------------------------------------- /frontend/src/page/submitButton.tsx: -------------------------------------------------------------------------------- 1 | import Box from "@material-ui/core/Box"; 2 | import Button from "@material-ui/core/Button"; 3 | import CircularProgress from "@material-ui/core/CircularProgress"; 4 | import React from "react"; 5 | 6 | interface SubmitButtonProps { 7 | submitting: boolean; 8 | } 9 | 10 | export const SubmitButton: React.FC = (props) => 11 | ; 21 | -------------------------------------------------------------------------------- /frontend/src/page/thread.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | API_ALL_THREADS_ID, 3 | API_POST_CREATE, 4 | API_REMIXED_THREADS_ID, 5 | API_THREAD_LIST_ENDING, 6 | API_TRENDING_THREADS_ID, 7 | API_VIEWED_THREAD, 8 | COLLECTION_LIKED, 9 | COLLECTION_POSTS, 10 | COLLECTION_USERS, 11 | ClientPost, 12 | StoredPost, 13 | StoredUser, 14 | makeLikedKey, 15 | userHasPermission 16 | } from "../../../common/common"; 17 | import { 18 | AbortablePromise, 19 | Auth, 20 | abortable, 21 | abortableJsonFetch, 22 | cancel, 23 | makeLocalUrl 24 | } from "../shared/shared"; 25 | import {PAGE_WIDTH, theme, useStyles} from "./style"; 26 | import Box from "@material-ui/core/Box"; 27 | import Card from "@material-ui/core/Card"; 28 | import CardContent from "@material-ui/core/CardContent"; 29 | import {LoginUserIdState} from "./login"; 30 | import Masonry from "react-masonry-css"; 31 | import {Post} from "./post"; 32 | import React from "react"; 33 | import {SubmitButton} from "./submitButton"; 34 | import TextField from "@material-ui/core/TextField"; 35 | import {store} from "../shared/firebase"; 36 | 37 | interface ThreadProps { 38 | // If this is set to API_ALL_THREADS_ID then it means we're listing all threads. 39 | threadId: string; 40 | history: import("history").History; 41 | loggedInUserId: LoginUserIdState; 42 | } 43 | 44 | const EMPTY_USERNAME = "\u3000"; 45 | 46 | export const Thread: React.FC = (props) => { 47 | const isThreadList = props.threadId.endsWith(API_THREAD_LIST_ENDING); 48 | const isSpecificThread = !isThreadList; 49 | // If we're on a specific thread, create a psuedo post for the first post that includes the video (loads quicker). 50 | const psuedoPosts: ClientPost[] = []; 51 | if (isSpecificThread) { 52 | psuedoPosts.push({ 53 | id: props.threadId, 54 | type: "thread", 55 | threadId: props.threadId, 56 | title: "", 57 | message: "", 58 | userdata: { 59 | type: "animation", 60 | attribution: [], 61 | width: 0, 62 | height: 0 63 | }, 64 | userId: "", 65 | replyId: null, 66 | dateMsSinceEpoch: Date.now(), 67 | likes: 0, 68 | likesSecondsFromBirthAverage: 0, 69 | trendingScore: 0, 70 | views: 0, 71 | username: EMPTY_USERNAME, 72 | avatarId: null, 73 | liked: false, 74 | canDelete: false 75 | }); 76 | } 77 | const [posts, setPosts] = React.useState(psuedoPosts); 78 | 79 | const [storedPosts, setStoredPosts] = React.useState([]); 80 | 81 | React.useEffect(() => { 82 | if (isSpecificThread) { 83 | // Let the server know that we viewed this thread or remix (don't need to do anything with the result). 84 | const threadId = location.hash ? location.hash.slice(1) : props.threadId; 85 | abortableJsonFetch(API_VIEWED_THREAD, Auth.Optional, {threadId}); 86 | } 87 | const postCollection = store.collection(COLLECTION_POSTS); 88 | 89 | const postQueries = (() => { 90 | switch (props.threadId) { 91 | case API_ALL_THREADS_ID: 92 | return postCollection. 93 | where("type", "==", "thread"). 94 | orderBy("dateMsSinceEpoch", "desc"). 95 | limit(20); 96 | case API_TRENDING_THREADS_ID: 97 | return postCollection. 98 | where("type", "==", "thread"). 99 | orderBy("trendingScore", "desc"). 100 | limit(6); 101 | case API_REMIXED_THREADS_ID: 102 | return postCollection. 103 | where("type", "==", "remix"). 104 | orderBy("dateMsSinceEpoch", "desc"). 105 | limit(20); 106 | default: 107 | return postCollection. 108 | where("threadId", "==", props.threadId). 109 | orderBy("dateMsSinceEpoch", "desc"); 110 | } 111 | })(); 112 | 113 | const postListPromise = abortable(postQueries.get()); 114 | postListPromise.then((postDocs) => { 115 | if (postDocs) { 116 | const postList = postDocs.docs.map((snapshot) => snapshot.data()) as StoredPost[]; 117 | if (isSpecificThread) { 118 | postList.reverse(); 119 | } 120 | setPosts(postList.map((storedPost) => ({ 121 | ...storedPost, 122 | // This is a special space that still takes up room. 123 | username: EMPTY_USERNAME, 124 | avatarId: null, 125 | liked: false, 126 | canDelete: storedPost.userId === props.loggedInUserId 127 | }))); 128 | setStoredPosts(postList); 129 | } 130 | }); 131 | 132 | return () => { 133 | cancel(postListPromise); 134 | }; 135 | }, []); 136 | 137 | React.useEffect(() => { 138 | if (typeof props.loggedInUserId === "undefined" || storedPosts.length === 0) { 139 | return () => 0; 140 | } 141 | const amendedPostPromise = abortable((async () => { 142 | const loggedInUser = props.loggedInUserId 143 | ? (await store.collection(COLLECTION_USERS).doc(props.loggedInUserId).get()).data() as StoredUser 144 | : null; 145 | 146 | const clientPosts: ClientPost[] = await Promise.all(storedPosts.map(async (storedPost) => { 147 | const userDoc = await store.collection(COLLECTION_USERS).doc(storedPost.userId).get(); 148 | const user = userDoc.data() as StoredUser | undefined; 149 | return { 150 | ...storedPost, 151 | username: user ? user.username : "", 152 | avatarId: user ? user.avatarId : null, 153 | liked: props.loggedInUserId 154 | ? (await store.collection(COLLECTION_LIKED).doc(makeLikedKey( 155 | storedPost.id, 156 | props.loggedInUserId 157 | )).get()).exists 158 | : false, 159 | canDelete: userHasPermission(loggedInUser, storedPost.userId) 160 | }; 161 | })); 162 | return clientPosts; 163 | })()); 164 | 165 | amendedPostPromise.then((clientPosts) => { 166 | if (clientPosts) { 167 | setPosts(clientPosts); 168 | } 169 | }); 170 | 171 | return () => { 172 | cancel(amendedPostPromise); 173 | }; 174 | }, [props.loggedInUserId, storedPosts]); 175 | 176 | const [postMessage, setPostMessage] = React.useState(""); 177 | const [postCreateFetch, setPostCreateFetch] = React.useState>(null); 178 | 179 | React.useEffect(() => () => { 180 | cancel(postCreateFetch); 181 | }, []); 182 | 183 | const classes = useStyles(); 184 | 185 | const breakpointCols = (() => { 186 | if (isThreadList) { 187 | const maxColumns = 3; 188 | const columnFixedSize = PAGE_WIDTH / maxColumns; 189 | const columns: { default: number; [key: number]: number } = {default: maxColumns}; 190 | for (let i = maxColumns - 1; i >= 1; --i) { 191 | columns[(i + 1) * columnFixedSize] = i; 192 | } 193 | return columns; 194 | } 195 | return 1; 196 | })(); 197 | 198 | return
199 | 203 | {posts.map((post) => (event.target as HTMLVideoElement).play().catch(() => 0), 213 | onMouseLeave: (event) => (event.target as HTMLVideoElement).pause(), 214 | onTouchStart: (event) => { 215 | const element = event.target as HTMLVideoElement; 216 | element.focus({preventScroll: true}); 217 | element.play().catch(() => 0); 218 | }, 219 | onBlur: (event) => (event.target as HTMLVideoElement).pause() 220 | } 221 | : {autoPlay: true}} 222 | onClick={ 223 | isThreadList 224 | ? () => { 225 | const hash = post.type === "remix" ? post.id : undefined; 226 | props.history.push(makeLocalUrl("/thread", {threadId: post.threadId}, hash)); 227 | } 228 | : null} 229 | history={props.history} 230 | onTrashed={() => { 231 | if (post.id === post.threadId) { 232 | // Deleting the entire thread, so go back to home/root and don't keep this entry in history. 233 | props.history.replace(makeLocalUrl("/")); 234 | } else { 235 | // Remove the post from the list. 236 | setPosts((previous) => { 237 | const newPosts = [...previous]; 238 | const index = newPosts.indexOf(post); 239 | newPosts.splice(index, 1); 240 | return newPosts; 241 | }); 242 | } 243 | }}/>)} 244 | 245 | { 246 | isSpecificThread 247 | ? 248 | 249 |
{ 251 | e.preventDefault(); 252 | const postCreateFetchPromise = abortableJsonFetch(API_POST_CREATE, Auth.Required, { 253 | message: postMessage, 254 | threadId: props.threadId 255 | }); 256 | setPostCreateFetch(postCreateFetchPromise); 257 | 258 | try { 259 | const newPost = await postCreateFetchPromise; 260 | if (newPost) { 261 | // Append our post to the end. 262 | setPosts((previous) => [ 263 | ...previous, 264 | newPost 265 | ]); 266 | } 267 | } finally { 268 | setPostCreateFetch(null); 269 | } 270 | setPostMessage(""); 271 | }}> 272 | { 283 | setPostMessage(e.target.value); 284 | }}/> 285 | 286 | 288 | Post 289 | 290 | 291 | 292 |
293 |
294 | : null 295 | } 296 |
; 297 | }; 298 | -------------------------------------------------------------------------------- /frontend/src/page/trashButton.tsx: -------------------------------------------------------------------------------- 1 | import {API_POST_DELETE, ClientPost} from "../../../common/common"; 2 | import {Auth, abortableJsonFetch} from "../shared/shared"; 3 | import DeleteIcon from "@material-ui/icons/Delete"; 4 | import IconButton from "@material-ui/core/IconButton"; 5 | import React from "react"; 6 | 7 | interface TrashButtonProps { 8 | post: ClientPost; 9 | onTrashed: () => void; 10 | } 11 | 12 | export const TrashButton: React.FC = (props) => 13 | { 14 | await abortableJsonFetch(API_POST_DELETE, Auth.Required, {id: props.post.id}); 15 | props.onTrashed(); 16 | }}> 17 | 18 | ; 19 | -------------------------------------------------------------------------------- /frontend/src/page/userAvatar.tsx: -------------------------------------------------------------------------------- 1 | import {API_PROFILE_AVATAR} from "../../../common/common"; 2 | import Avatar from "@material-ui/core/Avatar"; 3 | import React from "react"; 4 | import {makeServerUrl} from "../shared/shared"; 5 | import {theme} from "./style"; 6 | 7 | interface UserAvatarProps { 8 | username: string; 9 | avatarId: string | null; 10 | } 11 | 12 | export const UserAvatar: React.FC = (props) => 13 | 19 | {props.username.slice(0, 1).toUpperCase()} 20 | ; 21 | -------------------------------------------------------------------------------- /frontend/src/public/LICENSE_OFL.txt: -------------------------------------------------------------------------------- 1 | This Font Software is licensed under the SIL Open Font License, 2 | Version 1.1. 3 | 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | ----------------------------------------------------------- 8 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 9 | ----------------------------------------------------------- 10 | 11 | PREAMBLE 12 | The goals of the Open Font License (OFL) are to stimulate worldwide 13 | development of collaborative font projects, to support the font 14 | creation efforts of academic and linguistic communities, and to 15 | provide a free and open framework in which fonts may be shared and 16 | improved in partnership with others. 17 | 18 | The OFL allows the licensed fonts to be used, studied, modified and 19 | redistributed freely as long as they are not sold by themselves. The 20 | fonts, including any derivative works, can be bundled, embedded, 21 | redistributed and/or sold with any software provided that any reserved 22 | names are not used by derivative works. The fonts and derivatives, 23 | however, cannot be released under any other type of license. The 24 | requirement for fonts to remain under this license does not apply to 25 | any document created using the fonts or their derivatives. 26 | 27 | DEFINITIONS 28 | "Font Software" refers to the set of files released by the Copyright 29 | Holder(s) under this license and clearly marked as such. This may 30 | include source files, build scripts and documentation. 31 | 32 | "Reserved Font Name" refers to any names specified as such after the 33 | copyright statement(s). 34 | 35 | "Original Version" refers to the collection of Font Software 36 | components as distributed by the Copyright Holder(s). 37 | 38 | "Modified Version" refers to any derivative made by adding to, 39 | deleting, or substituting -- in part or in whole -- any of the 40 | components of the Original Version, by changing formats or by porting 41 | the Font Software to a new environment. 42 | 43 | "Author" refers to any designer, engineer, programmer, technical 44 | writer or other person who contributed to the Font Software. 45 | 46 | PERMISSION & CONDITIONS 47 | Permission is hereby granted, free of charge, to any person obtaining 48 | a copy of the Font Software, to use, study, copy, merge, embed, 49 | modify, redistribute, and sell modified and unmodified copies of the 50 | Font Software, subject to the following conditions: 51 | 52 | 1) Neither the Font Software nor any of its individual components, in 53 | Original or Modified Versions, may be sold by itself. 54 | 55 | 2) Original or Modified Versions of the Font Software may be bundled, 56 | redistributed and/or sold with any software, provided that each copy 57 | contains the above copyright notice and this license. These can be 58 | included either as stand-alone text files, human-readable headers or 59 | in the appropriate machine-readable metadata fields within text or 60 | binary files as long as those fields can be easily viewed by the user. 61 | 62 | 3) No Modified Version of the Font Software may use the Reserved Font 63 | Name(s) unless explicit written permission is granted by the 64 | corresponding Copyright Holder. This restriction only applies to the 65 | primary font name as presented to the users. 66 | 67 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 68 | Software shall not be used to promote, endorse or advertise any 69 | Modified Version, except to acknowledge the contribution(s) of the 70 | Copyright Holder(s) and the Author(s) or with their explicit written 71 | permission. 72 | 73 | 5) The Font Software, modified or unmodified, in part or in whole, 74 | must be distributed entirely under this license, and must not be 75 | distributed under any other license. The requirement for fonts to 76 | remain under this license does not apply to any document created using 77 | the Font Software. 78 | 79 | TERMINATION 80 | This license becomes null and void if any of the above conditions are 81 | not met. 82 | 83 | DISCLAIMER 84 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 85 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 86 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 87 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 88 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 89 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 90 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 91 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 92 | OTHER DEALINGS IN THE FONT SOFTWARE. 93 | -------------------------------------------------------------------------------- /frontend/src/public/arvo.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorSundberg/gifygram/3fa36675649db14321f74433e0a32d2ba3accb93/frontend/src/public/arvo.ttf -------------------------------------------------------------------------------- /frontend/src/public/giphy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorSundberg/gifygram/3fa36675649db14321f74433e0a32d2ba3accb93/frontend/src/public/giphy.png -------------------------------------------------------------------------------- /frontend/src/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorSundberg/gifygram/3fa36675649db14321f74433e0a32d2ba3accb93/frontend/src/public/icon.png -------------------------------------------------------------------------------- /frontend/src/public/sample.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorSundberg/gifygram/3fa36675649db14321f74433e0a32d2ba3accb93/frontend/src/public/sample.mp4 -------------------------------------------------------------------------------- /frontend/src/public/sample.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorSundberg/gifygram/3fa36675649db14321f74433e0a32d2ba3accb93/frontend/src/public/sample.webm -------------------------------------------------------------------------------- /frontend/src/shared/firebase.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | // eslint-disable-next-line sort-imports 3 | import "firebase/auth"; 4 | import "firebase/analytics"; 5 | import "firebase/firestore"; 6 | import {isDevEnvironment} from "./shared"; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-var-requires 9 | firebase.initializeApp(require("../../firebaseOptions")); 10 | firebase.analytics(); 11 | 12 | if (isDevEnvironment()) { 13 | firebase.firestore().settings({ 14 | host: `${new URL(window.location.href).hostname}:5003`, 15 | ssl: false 16 | }); 17 | } 18 | 19 | export const store = firebase.firestore(); 20 | -------------------------------------------------------------------------------- /frontend/src/shared/shared.ts: -------------------------------------------------------------------------------- 1 | import {Api} from "../../../common/common"; 2 | import firebase from "firebase/app"; 3 | 4 | export const EVENT_REQUEST_LOGIN = "requestLogin"; 5 | export const EVENT_MENU_OPEN = "menuOpen"; 6 | 7 | export type NeverAsync = T; 8 | 9 | export class Deferred implements Promise { 10 | private resolveSelf; 11 | 12 | private rejectSelf; 13 | 14 | private promise: Promise 15 | 16 | public constructor () { 17 | this.promise = new Promise((resolve, reject) => { 18 | this.resolveSelf = resolve; 19 | this.rejectSelf = reject; 20 | }); 21 | } 22 | 23 | public then ( 24 | onfulfilled?: ((value: T) => 25 | TResult1 | PromiseLike) | undefined | null, 26 | onrejected?: ((reason: any) => 27 | TResult2 | PromiseLike) | undefined | null 28 | ): Promise { 29 | return this.promise.then(onfulfilled, onrejected); 30 | } 31 | 32 | public catch (onrejected?: ((reason: any) => 33 | TResult | PromiseLike) | undefined | null): Promise { 34 | return this.promise.then(onrejected); 35 | } 36 | 37 | public finally (onfinally?: (() => void) | undefined | null): Promise { 38 | console.log(onfinally); 39 | throw new Error("Not implemented"); 40 | } 41 | 42 | public resolve (val: T) { 43 | this.resolveSelf(val); 44 | } 45 | 46 | public reject (reason: any) { 47 | this.rejectSelf(reason); 48 | } 49 | 50 | public [Symbol.toStringTag]: "Promise" 51 | } 52 | 53 | export class RequestLoginEvent extends Event { 54 | public deferredLoginPicked = new Deferred(); 55 | } 56 | 57 | // Assume we're in dev if the protocol is http: (not https:) 58 | export const isDevEnvironment = () => window.location.protocol === "http:"; 59 | 60 | export interface AuthUser { 61 | jwt: string; 62 | id: string; 63 | } 64 | 65 | export const signInIfNeeded = async () => { 66 | if (firebase.auth().currentUser) { 67 | return; 68 | } 69 | 70 | const requestLogin = new RequestLoginEvent(EVENT_REQUEST_LOGIN); 71 | window.dispatchEvent(requestLogin); 72 | 73 | await requestLogin.deferredLoginPicked; 74 | }; 75 | 76 | export const signOut = () => firebase.auth().signOut(); 77 | 78 | const applyPathAndParams = (url: URL, path: string, params?: Record) => { 79 | url.pathname = path; 80 | if (params) { 81 | for (const key of Object.keys(params)) { 82 | const value = params[key]; 83 | if (typeof value !== "undefined") { 84 | url.searchParams.set(key, JSON.stringify(value)); 85 | } 86 | } 87 | } 88 | }; 89 | 90 | export const makeServerUrl = (api: Api, params: InputType = null) => { 91 | const url = new URL(window.location.origin); 92 | applyPathAndParams(url, api.pathname, params); 93 | return url.href; 94 | }; 95 | 96 | export const makeFullLocalUrl = (path: string, params?: Record, hash?: string) => { 97 | const url = new URL(window.location.origin); 98 | applyPathAndParams(url, path, params); 99 | if (hash) { 100 | url.hash = hash; 101 | } 102 | return url.href; 103 | }; 104 | 105 | export const makeLocalUrl = (path: string, params?: Record, hash?: string) => { 106 | // Always return a rooted url without the origin: /something 107 | const url = new URL(makeFullLocalUrl(path, params, hash)); 108 | return url.href.substr(url.origin.length); 109 | }; 110 | 111 | export interface MergableItem { 112 | id: string; 113 | } 114 | 115 | export class NonAlertingError extends Error {} 116 | 117 | export interface ResponseJson { 118 | err?: string; 119 | stack?: string; 120 | } 121 | 122 | export const checkResponseJson = (json: T) => { 123 | if (json.err) { 124 | console.warn(json); 125 | const error = new Error(json.err); 126 | error.stack = json.stack; 127 | throw error; 128 | } 129 | return json; 130 | }; 131 | 132 | export type AbortablePromise = Promise & {controller: AbortController}; 133 | 134 | export enum Auth { 135 | None, 136 | Optional, 137 | Required, 138 | } 139 | 140 | export const abortable = (promise: Promise, abortController?: AbortController): AbortablePromise => { 141 | const controller = abortController || new AbortController(); 142 | const abortablePromise = new Promise((resolve, reject) => { 143 | promise.then((value) => { 144 | if (controller.signal.aborted) { 145 | resolve(null); 146 | } else { 147 | resolve(value); 148 | } 149 | }, reject); 150 | }) as AbortablePromise; 151 | abortablePromise.controller = controller; 152 | return abortablePromise; 153 | }; 154 | 155 | let fetchQueue: Promise = Promise.resolve(); 156 | 157 | export const abortableJsonFetch = ( 158 | api: Api | string, 159 | auth: Auth = Auth.Optional, 160 | params: InputType = null, 161 | body: BodyInit = null, 162 | options: RequestInit = null): AbortablePromise => { 163 | const controller = new AbortController(); 164 | const promise = (async () => { 165 | if (isDevEnvironment()) { 166 | // Serialize fetch in dev because Firestore transactions fail if more than one is going on the emulator. 167 | try { 168 | await fetchQueue; 169 | // eslint-disable-next-line no-empty 170 | } catch {} 171 | } 172 | if (auth === Auth.Required) { 173 | await signInIfNeeded(); 174 | } 175 | const authHeaders = firebase.auth().currentUser && auth !== Auth.None 176 | ? {Authorization: await firebase.auth().currentUser.getIdToken()} 177 | : null; 178 | try { 179 | const response = await fetch(typeof api === "string" 180 | ? api 181 | : makeServerUrl(api, params), { 182 | signal: controller.signal, 183 | method: "POST", 184 | body, 185 | headers: { 186 | ...authHeaders, 187 | "content-type": "application/octet-stream" 188 | }, 189 | ...options 190 | }); 191 | 192 | let json: any = null; 193 | try { 194 | json = await response.json(); 195 | } catch (err) { 196 | if (response.status === 200) { 197 | throw err; 198 | } else { 199 | throw new Error(response.statusText); 200 | } 201 | } 202 | 203 | return checkResponseJson(json); 204 | } catch (err) { 205 | if (err instanceof Error && err.name === "AbortError") { 206 | return null; 207 | } 208 | throw err; 209 | } 210 | })(); 211 | fetchQueue = promise; 212 | return abortable(promise, controller); 213 | }; 214 | 215 | export const cancel = (promise: AbortablePromise) => promise && promise.controller.abort(); 216 | -------------------------------------------------------------------------------- /frontend/src/shared/unload.tsx: -------------------------------------------------------------------------------- 1 | import {Prompt} from "react-router-dom"; 2 | import React from "react"; 3 | import {isDevEnvironment} from "./shared"; 4 | 5 | export const EVENT_UNSAVED_CHANGES = "unsavedChanges"; 6 | 7 | export const DiscardChangesMessage = "Do you want to leave this page and discard any changes?"; 8 | 9 | export let hasUnsavedChanges = false; 10 | 11 | window.onbeforeunload = () => { 12 | if (hasUnsavedChanges && !isDevEnvironment()) { 13 | return DiscardChangesMessage; 14 | } 15 | return undefined; 16 | }; 17 | 18 | export const setHasUnsavedChanges = (value: boolean) => { 19 | hasUnsavedChanges = value; 20 | window.dispatchEvent(new Event(EVENT_UNSAVED_CHANGES)); 21 | }; 22 | 23 | export const UnsavedChangesPrompt: React.FC = () => { 24 | const [unsavedChanges, setUnsavedChanges] = React.useState(false); 25 | React.useEffect(() => { 26 | const onUnsavedChanges = () => { 27 | setUnsavedChanges(hasUnsavedChanges); 28 | }; 29 | window.addEventListener(EVENT_UNSAVED_CHANGES, onUnsavedChanges); 30 | return () => { 31 | window.removeEventListener(EVENT_UNSAVED_CHANGES, onUnsavedChanges); 32 | }; 33 | }, []); 34 | 35 | return ; 39 | }; 40 | -------------------------------------------------------------------------------- /frontend/src/tracking.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /frontend/title.js: -------------------------------------------------------------------------------- 1 | module.exports = "Gifygram"; 2 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "noImplicitAny": false, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "target": "ES6", 8 | "removeComments": false, 9 | "allowSyntheticDefaultImports": true, 10 | "jsx": "react", 11 | "allowJs": true, 12 | "esModuleInterop": true, 13 | "baseUrl": "./", 14 | "skipLibCheck": true, 15 | "skipDefaultLibCheck": true, 16 | "types": [ 17 | "node", 18 | "dom-mediacapture-record", 19 | "css-font-loading-module" 20 | ] 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "dist" 25 | ] 26 | } -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path"); 3 | const uuidv4 = require("uuid/v4"); 4 | const webpack = require("webpack"); 5 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 6 | const {CleanWebpackPlugin} = require("clean-webpack-plugin"); 7 | 8 | module.exports = { 9 | devServer: { 10 | port: 5005, 11 | hot: false, 12 | open: true, 13 | openPage: "http://localhost:5000/", 14 | historyApiFallback: true, 15 | writeToDisk: true 16 | }, 17 | devtool: "source-map", 18 | entry: "./src/index.tsx", 19 | module: { 20 | rules: [ 21 | { 22 | loader: "raw-loader", 23 | test: /\.html$/u 24 | }, 25 | { 26 | loader: "ts-loader", 27 | test: /\.tsx?$/u 28 | }, 29 | { 30 | include: /\.module\.css$/u, 31 | test: /\.css$/u, 32 | use: [ 33 | "style-loader", 34 | { 35 | loader: "css-loader", 36 | options: { 37 | importLoaders: 1, 38 | modules: true 39 | } 40 | } 41 | ] 42 | }, 43 | { 44 | exclude: /\.module\.css$/u, 45 | test: /\.css$/u, 46 | use: [ 47 | "style-loader", 48 | "css-loader" 49 | ] 50 | }, 51 | { 52 | test: /\.(png|mp4|webm)$/u, 53 | use: [ 54 | { 55 | loader: "url-loader", 56 | options: { 57 | limit: 4096, 58 | name: "public/[name]-[contenthash].[ext]" 59 | } 60 | } 61 | ] 62 | }, 63 | { 64 | test: /\.(woff|woff2|ttf|eot|svg)$/u, 65 | use: [ 66 | { 67 | loader: "file-loader", 68 | options: { 69 | name: "public/[name]-[contenthash].[ext]" 70 | } 71 | } 72 | ] 73 | } 74 | ] 75 | }, 76 | node: { 77 | fs: "empty" 78 | }, 79 | optimization: { 80 | }, 81 | output: { 82 | chunkFilename: "public/[name]-[chunkhash].js", 83 | filename: "public/[name]-[hash].js", 84 | path: path.join( 85 | __dirname, 86 | "../public" 87 | ) 88 | }, 89 | plugins: [ 90 | new CleanWebpackPlugin(), 91 | new HtmlWebpackPlugin({ 92 | template: "./src/index.htm", 93 | title: require("./title") 94 | }), 95 | new webpack.DefinePlugin({ 96 | CACHE_GUID: JSON.stringify(uuidv4()) 97 | }) 98 | ], 99 | resolve: { 100 | extensions: [ 101 | ".ts", 102 | ".tsx", 103 | ".js", 104 | ".css" 105 | ] 106 | } 107 | }; 108 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "build": "webpack --mode production", 5 | "liveWebpackFunctions": "webpack --mode development --watch", 6 | "liveFirebaseEmulator": "firebase emulators:start" 7 | }, 8 | "engines": { 9 | "node": "10" 10 | }, 11 | "main": "dist/main.js", 12 | "dependencies": { 13 | "@types/node-fetch": "^2.5.7", 14 | "ajv": "^6.12.3", 15 | "better-ajv-errors": "^0.6.7", 16 | "firebase-admin": "^9.0.0", 17 | "firebase-functions": "^3.9.0", 18 | "node-fetch": "^2.6.0", 19 | "uuidv4": "^6.2.0" 20 | }, 21 | "devDependencies": { 22 | "clean-webpack-plugin": "^3.0.0", 23 | "file-loader": "^6.0.0", 24 | "firebase-functions-test": "^0.2.0", 25 | "source-map-loader": "^1.0.1", 26 | "ts-loader": "^8.0.1", 27 | "typescript": "^3.8.0", 28 | "webpack": "^4.44.0", 29 | "webpack-cli": "^3.3.12" 30 | }, 31 | "private": true 32 | } 33 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "dist", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017" 10 | }, 11 | "include": [ 12 | "src" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /functions/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path"); 3 | const {CleanWebpackPlugin} = require("clean-webpack-plugin"); 4 | 5 | const mode = process.env.NODE_ENV || "production"; 6 | 7 | module.exports = { 8 | devtool: "source-map", 9 | entry: "./src/index.ts", 10 | externals: (context, request, callback) => { 11 | // Only bundle relative paths that start with . (e.g. './src/index.ts'). 12 | if ((/^\./u).test(request)) { 13 | return callback(); 14 | } 15 | return callback(null, `commonjs ${request}`); 16 | }, 17 | mode, 18 | module: { 19 | rules: [ 20 | { 21 | loader: "ts-loader", 22 | test: /\.ts$/u 23 | } 24 | ] 25 | }, 26 | optimization: {minimize: false}, 27 | output: { 28 | libraryTarget: "commonjs", 29 | filename: "[name].js", 30 | path: path.join(__dirname, "dist") 31 | }, 32 | plugins: [new CleanWebpackPlugin()], 33 | resolve: { 34 | extensions: [ 35 | ".ts", 36 | ".tsx", 37 | ".js" 38 | ] 39 | }, 40 | target: "node", 41 | watch: false 42 | }; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gifygram", 3 | "version": "1.0.0", 4 | "description": "A stupid website for animating images on top of videos.", 5 | "scripts": { 6 | "installTsSchemaLoader": "cd ts-schema-loader && npm install", 7 | "installDev": "cd dev && npm install", 8 | "installTest": "cd test && npm install", 9 | "installFrontend": "cd frontend && npm install", 10 | "installFunctions": "cd functions && npm install", 11 | "installAll": "npm install && npm run installTsSchemaLoader && npm run installDev && npm run installTest && npm run installFrontend && npm run installFunctions", 12 | "auditFixTsSchemaLoader": "cd ts-schema-loader && npm audit fix --force", 13 | "auditFixDev": "cd dev && npm audit fix --force", 14 | "auditFixTest": "cd test && npm audit fix --force", 15 | "auditFixFrontend": "cd frontend && npm audit fix --force", 16 | "auditFixFunctions": "cd functions && npm audit fix --force", 17 | "auditFixAll": "npm audit fix --force && npm run auditFixTsSchemaLoader && npm run auditFixDev && npm run auditFixTest && npm run auditFixFrontend && npm run auditFixFunctions", 18 | "buildTsSchemaLoader": "cd ts-schema-loader && npm run build", 19 | "buildFrontend": "cd frontend && npm run build", 20 | "buildFunctions": "cd functions && npm run build", 21 | "buildAll": "npm run installAll && npm run lint && npm run buildTsSchemaLoader && npm run buildFrontend && npm run buildFunctions", 22 | "deploy": "npm run buildAll && firebase deploy", 23 | "lint": "eslint --max-warnings 0 --ext .ts,.tsx .", 24 | "start": "cd dev && NODE_ENV=development npm run build && NODE_ENV=development node dist/main.js" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/TrevorSundberg/gifygram.git" 29 | }, 30 | "keywords": [], 31 | "author": "Trevor Sundberg", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/TrevorSundberg/gifygram/issues" 35 | }, 36 | "homepage": "https://github.com/TrevorSundberg/gifygram#readme", 37 | "dependencies": { 38 | "@types/json-schema": "^7.0.5", 39 | "@types/node": "^14.0.14", 40 | "@typescript-eslint/eslint-plugin": "^2.24.0", 41 | "@typescript-eslint/parser": "^2.24.0", 42 | "eslint": "^6.8.0", 43 | "eslint-plugin-react": "^7.19.0", 44 | "firebase-tools": "^8.7.0", 45 | "typescript": "^3.8.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorSundberg/gifygram/3fa36675649db14321f74433e0a32d2ba3accb93/preview.gif -------------------------------------------------------------------------------- /storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service firebase.storage { 3 | match /b/{bucket}/o { 4 | match /{allPaths=**} { 5 | allow read; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "liveWebpackTest": "NODE_ENV=development webpack" 5 | }, 6 | "dependencies": { 7 | "@types/node-fetch": "^2.5.7", 8 | "@types/puppeteer": "^3.0.1", 9 | "node-fetch": "^2.6.0", 10 | "puppeteer": "^5.1.0", 11 | "ts-loader": "^8.0.0", 12 | "webpack": "^4.43.0", 13 | "webpack-cli": "^3.3.12", 14 | "webpack-node-externals": "^2.5.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import {TEST_EMAIL, TEST_PASSWORD} from "../common/test"; 2 | import fetch from "node-fetch"; 3 | import puppeteer from "puppeteer"; 4 | 5 | const RUN_NUMBER = process.env.TEST_RUN_NUMBER; 6 | const MAIN_URL = "http://localhost:5000/"; 7 | const API_HEALTH_URL = `${MAIN_URL}api/health`; 8 | 9 | const waitForHealth = async (url: string) => { 10 | const timeoutMs = 500; 11 | for (;;) { 12 | try { 13 | const response = await fetch(url, {timeout: timeoutMs}); 14 | if (response.status === 200) { 15 | return; 16 | } 17 | break; 18 | } catch { 19 | await new Promise((resolve) => setTimeout(resolve, timeoutMs)); 20 | } 21 | } 22 | }; 23 | 24 | const click = async (page: puppeteer.Page, selector: string) => { 25 | await page.waitForSelector(selector, {visible: true}); 26 | await page.click(selector); 27 | }; 28 | 29 | const type = async (page: puppeteer.Page, selector: string, text: string) => { 30 | await page.waitForSelector(selector, {visible: true}); 31 | await page.type(selector, text); 32 | }; 33 | 34 | interface Rect { 35 | top: number; 36 | left: number; 37 | bottom: number; 38 | right: number; 39 | } 40 | 41 | interface Point { 42 | x: number; 43 | y: number; 44 | } 45 | 46 | const getRect = async (page: puppeteer.Page, selector: string): Promise => { 47 | const elementHandle = await page.waitForSelector(selector); 48 | return page.evaluate((element) => { 49 | const {top, left, bottom, right} = element.getBoundingClientRect(); 50 | return {top, left, bottom, right}; 51 | }, elementHandle); 52 | }; 53 | 54 | const getCenter = (rect: Rect): Point => ({ 55 | x: (rect.left + rect.right) / 2, 56 | y: (rect.top + rect.bottom) / 2 57 | }); 58 | 59 | (async () => { 60 | const browser = await puppeteer.launch({headless: false}); 61 | try { 62 | const page = await browser.newPage(); 63 | 64 | await waitForHealth(API_HEALTH_URL); 65 | await waitForHealth(MAIN_URL); 66 | 67 | await page.goto(MAIN_URL, {waitUntil: "networkidle2"}); 68 | 69 | // Start creating a new animation. 70 | await click(page, "#create"); 71 | 72 | await page.waitForSelector("#spinner-complete-0", {visible: true}); 73 | 74 | // Add text to the animation. 75 | await click(page, "#text"); 76 | await type(page, "#text-input", `TEST${RUN_NUMBER}`); 77 | await click(page, "#button-OK"); 78 | 79 | // Move the newly created text widget to the top left (first frame). 80 | const widgetCenter = getCenter(await getRect(page, ".widget")); 81 | const widgetsRect = await getRect(page, "#widgets"); 82 | await page.mouse.move(widgetCenter.x, widgetCenter.y); 83 | await page.mouse.down(); 84 | await page.mouse.move(widgetsRect.left, widgetsRect.top); 85 | await page.mouse.up(); 86 | 87 | // Move to the last frame on the animation timeline. 88 | const timelineRect = await getRect(page, ".videoTimeline"); 89 | await page.mouse.click(timelineRect.right - 1, timelineRect.top); 90 | 91 | // Move the text widget from the top left corner to the top right corner (adds a keyframe). 92 | await page.mouse.move(widgetsRect.left, widgetsRect.top); 93 | await page.mouse.down(); 94 | await page.mouse.move(widgetsRect.right, widgetsRect.top); 95 | await page.mouse.up(); 96 | 97 | // Post and render the animation. 98 | await click(page, "#post"); 99 | await type(page, "#post-title", `${RUN_NUMBER} This title takes space!`); 100 | await type(page, "#post-message", `${RUN_NUMBER} This is a test of then word wrapping or truncation features.`); 101 | await click(page, "#button-Post"); 102 | 103 | // When the login prompt appears click login with Email. 104 | await click(page, ".firebaseui-idp-password"); 105 | await type(page, "#ui-sign-in-email-input", TEST_EMAIL); 106 | await click(page, ".firebaseui-id-submit"); 107 | await type(page, "#ui-sign-in-password-input", TEST_PASSWORD); 108 | await click(page, ".firebaseui-id-submit"); 109 | await page.waitForSelector("video[autoplay]"); 110 | } finally { 111 | await browser.close(); 112 | } 113 | })(); 114 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "noImplicitAny": false, 5 | "module": "CommonJS", 6 | "moduleResolution": "Node", 7 | "target": "ES6", 8 | "removeComments": false, 9 | "esModuleInterop": true, 10 | "outDir": "dist" 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | "dist" 15 | ] 16 | } -------------------------------------------------------------------------------- /test/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const nodeExternals = require("webpack-node-externals"); 3 | 4 | module.exports = { 5 | entry: "./test.ts", 6 | externals: [nodeExternals()], 7 | mode: "development", 8 | module: { 9 | rules: [ 10 | { 11 | loader: "ts-loader", 12 | test: /\.tsx?$/u 13 | } 14 | ] 15 | }, 16 | resolve: { 17 | extensions: [ 18 | ".ts", 19 | ".tsx", 20 | ".js" 21 | ], 22 | plugins: [] 23 | }, 24 | target: "node", 25 | node: { 26 | __dirname: false 27 | }, 28 | watch: true 29 | }; 30 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | DONE - Refactored into classes 2 | DONE - Make it so we can add sprites 3 | DONE - Need a data structure to keep track of all the sprites and the video 4 | DONE - Create editable text 5 | DONE - Remove focus highlight 6 | DONE - Savable and loadable format 7 | DONE - Use a regex to parse the transform instead of a matrix, because it can't do multiple rotations! 8 | DONE - Need to make the widgets as if they are inside the video, and need to scale the video to the min dimension of the screen 9 | DONE - Code that copies a frame of the video so that the html2canvas code works 10 | DONE - Get canvas rendering because we know it's all consistent 11 | DONE - Get the text working with the animations, maybe need an animation event again like we had before (set value) 12 | DONE - Bug where we can't create new elements after loading (id conflict) use a guid for ids or something 13 | DONE - Concept of a selection, which widget we last clicked on 14 | DONE - Draw video and sprites to canvas 15 | DONE - Choose a framerate and play through each image, saving each into a png 16 | DONE - Encode canvas into video (mp4/webm) 17 | DONE - Touch does not capture on timeline (mobile) 18 | DONE - Make the ui more functional (icons, right top aligned, responsive to size changes, etc.) 19 | DONE - Get it built on travis-ci 20 | DONE - Get it deployed to github pages 21 | DONE - Move video controls and buttons to another div that doesn't get captured with html2canvas 22 | DONE - Add modal for saving and loading 23 | DONE - Add modal for rendering animation 24 | DONE - Install font-awesome locally instead of using a CDN 25 | DONE - Rendering of widgets seems to be slightly off in encoded video... 26 | DONE - Widget already exists bug 27 | DONE - CORS error because videoSrc has full path (https) 28 | DONE - Add cancel button for modals that take time 29 | DONE - Selection that doesn't rely on focus so we can click buttons (can get rid of focus hack too) 30 | DONE - Motion tracking 31 | DONE - To do motion, we select a single sprite and then click the motion button 32 | DONE - From the current point in the video, wherever the sprite is located, it will attempt to track it until you click stop 33 | DONE - So we'll play the video at half speed or so so you can respond to it 34 | DONE - This should make a continuous animation (something we can make as a concept on the timeline) 35 | DONE - Make our own timeline for the video/scene 36 | DONE - Play/pause button 37 | DONE - Scrub timeline (capture mouse) 38 | DONE - Play pause button does not update if video changed state externally (e.g. play() pause()) 39 | DONE - Gizmos create keyframes every time we select 40 | DONE - Changing selected widget does not update timeline keyframes 41 | DONE - Can't delete widgets anymore 42 | DONE - Draw all widgets using canvas 43 | DONE - Make transparent images and overlay them using ffmpeg (without capturing video snapshots) 44 | DONE - Dramatically speed up rendering by not using html2canvas 45 | DONE - Directly run scenejs/timline from the renderer 46 | DONE - Hide video during canvas rendering 47 | DONE - Properly pack/serve ffmpeg worker, don't use https://unpkg.com/@ffmpeg/ffmpeg@v0.5.2/dist/worker.min.js 48 | DONE - Make text via a modal text input (doesn't change, use svg) 49 | DONE - Fix text rendering 50 | DONE - Tabbing away from video as it's encoding messes it up (can just say don't tab away for now) 51 | DONE - Make a PR to allow searching stickers (checkbox for stickers) 52 | DONE - Turn the save load code into 'share' - we load the content from a url, better for users with same functionality 53 | DONE - Make background video choosable 54 | DONE - Add delete button (trash) and visual feedback, for mobile 55 | DONE - Button to hide/show sprites on specific frames (show as transparent while editing) 56 | DONE - Add 'editor' mode to renderer so we can draw transparent images 57 | DONE - Newlines in text are broken (either disable them or) 58 | DONE - Add full screen loading animation for blocking operations 59 | DONE - Only add keyframes for values that changed 60 | DONE - Import main video from giphy 61 | DONE - Import sprite from giphy 62 | DONE - Video sizing problem (fixed up to max) 63 | DONE - Remove sound from exported video 64 | DONE - Target size for images 65 | DONE - Fix gifs with transparent frames 66 | DONE - Android ffmpeg encoding doesn't seem to run (out of memory, break into chunks) 67 | DONE - Tweak ffmpeg settings to encode faster (-preset...) 68 | DONE - Range selection on timeline (last click and drag) 69 | DONE - Delete keyframes in selected timeline range 70 | DONE - Ask if you want to leave saved changes 71 | DONE - Ability to make a post and store it in KV as anonymous user (just json) 72 | DONE - Make the post from inside the app - make a new post button for now 73 | DONE - Pre-render video and upload it 74 | DONE - Generate a thumbnail and store it in another key 75 | DONE - API - List posts in order of creation date (limited to N) 76 | DONE - API - Fetch thumbnail 77 | DONE - API - Fetch video 78 | DONE - Get static content hosted 79 | DONE - Remove dependence on ffmpeg (too big) 80 | DONE - Rename posts to animations 81 | DONE - Make a post api for title/tags/message 82 | DONE - Get static react page that can query /post/list and show cards 83 | DONE - Ability to view single post video 84 | DONE - See comments on video (including first) 85 | DONE - Separate post button on editor with title and message 86 | DONE - Re-enable hidden video when encoding (remove that old code) 87 | DONE - Abillity to post comments on the view thread page 88 | DONE - Remix button that opens the code in the editor 89 | DONE - When posting a remix, post it as a child of the parent thread 90 | DONE - Validate parent threadId in post 91 | DONE - Bug: remixing a comment doesn't get its own threadID (remixing it fails) 92 | DONE - When posting a remix, don't accept a threadId, accept a parent remix id (replyId) 93 | DONE - Add replyId to posts so we can see who the replied to 94 | DONE - Add replyId link that uses hashtag to move up (also for remixes) 95 | DONE - Use hashtag # to scroll to your addition after posting 96 | DONE - Remove oldVersion for userdata 97 | DONE - Title and message/description 98 | DONE - Hover over thumbnail and play video 99 | DONE - Ability to open a post in its own page (where we will show comments) 100 | DONE - Comment on a post (every post itself is a comment too, just may or may not be the first / have a video) 101 | DONE - Remix (edit and repost on the same thread) 102 | DONE - Server correct mime type for video/thumbnail/json (and all json responses) 103 | DONE - Add "Login with Google" and an authorization test path for validating the JWT 104 | DONE - Re-encode thumbnail png to smaller dimension jpg 105 | DONE - Completely remove thumbnails, the videos are so small and load quickly anyways, less code! 106 | DONE - Do all validation (such as for the video) before creating the post 107 | DONE - Ability to login 108 | DONE - Get shareable link 109 | DONE - Make the login required for on postCreate paths (client & server) 110 | DONE - Bug: Google async/defer gapi script can not exist (need to wait for onload 111 | DONE - Switch to using react-router 112 | DONE - Bug: componentDidMount makes fetch promises that must be cancelled in componentWillUnmount (React warning) 113 | DONE - Store user id (from google) along with posts 114 | DONE - Refactor all post data to be in a single json document 115 | DONE - Display user ids in thread comments 116 | DONE - Replace Modal, ModalProgress with material UI 117 | DONE - Replace /threads with / and make the editor into /editor 118 | DONE - Bug: Some gif animations are too fast (min frame rate?) 119 | DONE - Store width/height of video so we can reserve video element size (layout) 120 | DONE - Remove firstFrameJpeg / downsampleCanvas / thumbnail (unused) 121 | DONE - Titles are only for animations 122 | DONE - Need to clear message after its done 123 | DONE - Find a way to run workers/workers KV locally so we can simulate the entire environment 124 | DONE - Share Card implementation in thread/threads 125 | DONE - Like post or comment - (approximate, non-atomic and underestimate + actual vote by user) 126 | DONE - Just make authorization happen right at the beginning of CF requests and it sets it on RequestInput 127 | DONE - Make the share button work 128 | DONE - Need to support Accept-Ranges/Range requests and 206 Partial Content from CF Workers (Safari/iOS) 129 | DONE - Make the comment box just work on submit (enter/ mobile send) 130 | DONE - Upgrade moveable and test new mobile touch controls 131 | DONE - When you login, the page doesn't update (maybe just refresh, or set global state) 132 | DONE - Make psuedo posts come from the server response on create 133 | DONE - Make the share on the same line as a comment post (like button in its own class if needed) 134 | DONE - Maybe we make a react specific path for sign-in that does the full screen dialog (sends events to the root app) 135 | DONE - Bug: Keep posting/rendering modal up while logging in (or spinner? block basically & catch error on failure) 136 | DONE - Make a system where we store changes in a local cache and append them until they are confirmed added 137 | DONE - Need to attribute original gifs (attributions button that does a popup with a scroll list, show gif or image in list) 138 | DONE - Last touched video continues playing (don't require touch down) 139 | DONE - Ability to delete if you made a post 140 | DONE - Move all database operations into its own file 141 | DONE - Bug: warning: Form submission canceled because the form is not connected (on posting animation) 142 | DONE - Separate out the likes / views path to speed up how quickly we can show pages 143 | DONE - Make the site slightly less wide so that tall videos show up on desktop 144 | DONE - Make API routes type safe 145 | DONE - Rename post/view* to thread/view and make views only show on threads (also don't query views if threadId !== id) 146 | DONE - Make LoginUserIdContext be a three state login so we know when it's not known yet 147 | DONE - Redo validation to use JSON schemas (always POST JSON) 148 | DONE - Make sure extra data in JSON is considered invalid 149 | DONE - Validate that profile fails (extra parameter) 150 | DONE - Change the API so that all inputs and outputs are objects (not arrays, etc) 151 | DONE - Enforce all the constraints (max min, etc) 152 | DONE - Optimize the ts loader to only process a file once and extract all classes into an array (or separate files) 153 | DONE - Change 'value' to 'liked' in API_POST_LIKE 154 | DONE - Remove constants and just have a way to output the schemas 155 | DONE - Make a page where you can edit your profile 156 | DONE - Bug: Remixes of remixes aren't going to the right thread 157 | DONE - Trending 158 | DONE - Global admin role (and ability to assign other roles from editing database) 159 | DONE - Admin can delete posts 160 | DONE - Track views / popularity (approximate, non-atomic and underestimate + actual view by user) 161 | DONE - Add a test that we can run in development mode 162 | DONE - Remove content jquery from modal and remove the share button 163 | DONE - The makeServerUrl function should type enforce parameters 164 | DONE - Add a loading spinner to profile 165 | DONE - Add padding to the main screen so mobile doesn't look so bad 166 | DONE - Make sure usernames are unique since we can pick them at the moment 167 | DONE - Add username sanitization and checking 168 | DONE - Integrate google analytics to get stats tracking 169 | DONE - Loading screen for picking background video 170 | DONE - Usage ajv and typescript schemas or something similar to validate searialized json data (client loading and server) 171 | DONE - First remove all the old code 172 | DONE - Move auth over 173 | DONE - Maybe setup hosting emulator 174 | DONE - Remove options and access control headers 175 | DONE - Fix tests to run on firebasew 176 | DONE - Update all the ports used by the hosting emulator (and webpack dev server) 5000 and up... 177 | DONE - Add is dev environment check back, and in production don't allow posts from test user 178 | DONE - Turn all ArrayBuffer on the server to just Buffer 179 | DONE - Read and profile user using firestore rather than db functions 180 | DONE - Remove cache.ts since it's not needed 181 | DONE - Bug: Once you've closed login you can't do it again 182 | DONE - Refactor handlers to be independent of Workers 183 | DONE - Hook up the gifygram.com domain to point at it (buy a new service?) 184 | DONE - When we pick a username is has to be unique 185 | DONE - Move all the database code back into handlers 186 | DONE - Put likes and views directly on the post itself - remove them from metadata 187 | DONE - Remove list cache 188 | DONE - Make sure Firefox works 189 | DONE - Hook up firebase analytics 190 | DONE - Import main video from disk 191 | DONE - Import sprite from disk 192 | DONE - Remove replying to for now 193 | DONE - Start all sprites & text in the center and auto keyframe them 194 | DONE - Use the same font for text in editor as we do for our logo (Arvo) 195 | DONE - Bug: Clear keyframes in range does ALL keyframes, not just selected object keyframes 196 | DONE - Warnings on all buttons that require a selection 197 | DONE - Put deletes into transaction & delete views / likes / related posts 198 | DONE - Rename the repo 199 | DONE - Refactor CircularProgress spinner into a simple class (centered vertically too) 200 | DONE - Make editor load in async so we reduce main.js size (most people will just browse) 201 | DONE - Confirmation of destructive action on leaving editor (unsaved changes) 202 | DONE - Figure out the whole reply id thing 203 | DONE - No empty comments 204 | DONE - Ensure background never hides buttons in editor 205 | DONE - Highlight key frames as they are selected 206 | DONE - Ability to replace or change text (interchange too) 207 | DONE - Bug: On load does not clear confirmation of destructive action 208 | DONE - Scenejs Keyframe bug where it doesn't seek correctly 209 | DONE - Timestamp or "minutes ago" on comments / animation posts 210 | DONE - Make remixes appear on the front page 211 | DONE - Need to make front page remix link use a hashtag to scroll to the post 212 | DONE - Need to track views per remix (use url hash) 213 | DONE - Turn amended requests into direct firebase db access if possible 214 | DONE - Redirect madeitfor.fun to gifygram.com 215 | DONE - Remove the whole cached boolean and pending icon 216 | DONE - Bug: Motion tracked object gets deselected on occasio 217 | DONE - Custom dialog for listing stickers / videos 218 | DONE - Replace the buttons with material ui buttons 219 | 220 | - Tutorial video for animator 221 | - Agree privacy / etc 222 | - Link user ids in thread/threads to profile page (/user/someone) 223 | - Tags/keywords 224 | - Infinite scrolling using masonry 225 | - Flag post or comment 226 | - Admin view only flagged posts 227 | - Search by tag 228 | - View a user profile 229 | - Points for user profile 230 | - Show posts on a user profile 231 | - Admin can ban/unban user on profile 232 | - Reply button on the comments like Scratch 233 | - Replies are tabbed in (just one level) 234 | - Ability to type @someone in comments 235 | - Remove Font Awesome (play/pause button is the only usage) 236 | 237 | - Animation tutorial 238 | - Picking the background video 239 | - Adding a sprite 240 | - Animating the sprite from one position to another 241 | - Adding text 242 | - Deleting an object 243 | - Motion tracking 244 | - Post your animation 245 | 246 | - Get a staging environment 247 | - Get individual dev environments for testing (copy of database?) 248 | - Get publish to production using CI and npm version ... 249 | - Get tests running on CI builds (docker/puppeteer) 250 | 251 | - Bug: Motion track first/last frame is offset (almost like it's wrapping first/last) 252 | - Bug: Widgets after encoding does not always match current video frame (widgets in wrong position) 253 | - Visiblity toggle kinda sucks in animator, we should just make it a timeline thing 254 | - Import main video from youtube (clip the video) 255 | - Export dialog (gif, mp4, webm), fps, max size / keep aspect, etc... 256 | - Customize text styles (font, color, outline, etc) 257 | 258 | - Hire animators and meme creators to create stupid meme content 259 | 260 | - Facial recognition 261 | - Green screening 262 | - Frame by frame masking 263 | 264 | Sound (postponed): 265 | - Use media scene and change to playing the scenejs rather than seeking it on every video frame 266 | - Individual audio elements need to seek properly 267 | - Use media scene delay to set audio start time 268 | - Create sound object 269 | - Play sounds as we seek / animate 270 | - Import sound from disk 271 | - Sound from "text to speech" 272 | - Import sound from some other sound service 273 | - Enable exported video sound (remove -an in videoEncoder.ts) 274 | -------------------------------------------------------------------------------- /ts-schema-loader/main.ts: -------------------------------------------------------------------------------- 1 | import * as TJS from "typescript-json-schema"; 2 | import Ajv from "ajv"; 3 | import ajvPack from "ajv-pack"; 4 | import crypto from "crypto"; 5 | import fs from "fs"; 6 | import util from "util"; 7 | 8 | const ajv = new Ajv({sourceCode: true, jsonPointers: true}); 9 | 10 | const settings: TJS.PartialArgs = { 11 | excludePrivate: true, 12 | ref: false, 13 | required: true, 14 | strictNullChecks: true, 15 | noExtraProps: true 16 | }; 17 | 18 | const generatorCache: Record = {}; 19 | 20 | export default function (this: import("webpack").loader.LoaderContext) { 21 | const schemaRegex = /(?.*)\?(?&)?(?.*)/gu; 22 | // eslint-disable-next-line no-invalid-this 23 | const result = schemaRegex.exec(this.resource); 24 | if (!result) { 25 | throw Error("The format is require('ts-schema-loader!./your-file.ts?YourType')'"); 26 | } 27 | 28 | const hash = crypto.createHash("md5"). 29 | update(fs.readFileSync(result.groups.tsFile, "utf8")). 30 | digest("hex"); 31 | 32 | if (!generatorCache[hash]) { 33 | const files = [result.groups.tsFile]; 34 | const program = TJS.getProgramFromFiles(files, {strictNullChecks: true}); 35 | generatorCache[hash] = TJS.buildGenerator(program, settings, files); 36 | } 37 | 38 | const generator = generatorCache[hash]; 39 | const schema = generator.getSchemaForSymbol(result.groups.type); 40 | 41 | if (result.groups.debug) { 42 | console.log(util.inspect(schema, false, null, true)); 43 | } 44 | 45 | const validationFunction = ajv.compile(schema); 46 | const moduleCode: string = ajvPack(ajv, validationFunction); 47 | return `${moduleCode}\nmodule.exports.schema = ${JSON.stringify(schema, null, 2)};`; 48 | } 49 | -------------------------------------------------------------------------------- /ts-schema-loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-to-ajv-loader", 3 | "version": "1.0.0", 4 | "description": "Convert TypeScript interfaces into AJV validation functions.", 5 | "scripts": { 6 | "build": "tsc" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/TrevorSundberg/gifygram.git" 11 | }, 12 | "keywords": [], 13 | "author": "Trevor Sundberg", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/TrevorSundberg/gifygram/issues" 17 | }, 18 | "homepage": "https://github.com/TrevorSundberg/gifygram#readme", 19 | "dependencies": { 20 | "@types/webpack": "^4.41.18", 21 | "ajv": "^6.12.3", 22 | "ajv-pack": "^0.3.1", 23 | "typescript-json-schema": "^0.42.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ts-schema-loader/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "noImplicitAny": false, 5 | "module": "CommonJS", 6 | "moduleResolution": "Node", 7 | "target": "ES6", 8 | "removeComments": false, 9 | "allowSyntheticDefaultImports": true, 10 | "allowJs": true, 11 | "esModuleInterop": true, 12 | "outDir": "dist" 13 | }, 14 | "exclude": [ 15 | "node_modules", 16 | "dist" 17 | ] 18 | } --------------------------------------------------------------------------------