`);
31 | $(document.body).append(this.complete);
32 | }
33 | --this.count;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 | {
26 | if (ref) {
27 | (ref as any).disableRemotePlayback = true;
28 | }
29 | }}
30 | width={props.width}
31 | height={props.height}
32 | className={classes.video}
33 | muted
34 | loop
35 | src={makeServerUrl(API_ANIMATION_VIDEO, {id: props.id})}
36 | {...props}>
37 |
38 |
;
39 | };
40 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 ;
69 | }) as React.FC;
70 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | };
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 | {
76 | e.stopPropagation();
77 | setCopied(copy(props.url));
78 | }}>
79 | Copy
80 |
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/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/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 |
81 | Upload Avatar
82 | {
87 | const [file] = e.target.files;
88 | if (file) {
89 | setUserAvatar(file);
90 | }
91 | }}
92 | />
93 |
94 |
95 |
96 |
97 |
147 |
148 |
);
149 | };
150 |
--------------------------------------------------------------------------------
/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 |
69 | Upload
70 | {
75 | if (event.target.files.length) {
76 | const reader = new FileReader();
77 | const [file] = event.target.files;
78 | reader.readAsDataURL(file);
79 | reader.onload = () => {
80 | const dataUrl = reader.result as string;
81 | props.onSelect({
82 | originUrl: dataUrl,
83 | title: file.name,
84 | previewUrl: dataUrl,
85 | src: dataUrl,
86 | mimeType: file.type
87 | });
88 | };
89 | reader.onerror = () => {
90 | throw new Error("Unable to upload file");
91 | };
92 | }
93 | }}
94 | />
95 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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) => {
75 | if (props.defer) {
76 | props.defer.resolve(button);
77 | }
78 | if (button.callback) {
79 | button.callback(button);
80 | }
81 | // Delay by one frame to avoid the )
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 | ?
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/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/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 | {
125 | e.stopPropagation();
126 | props.history.push(makeLocalUrl("/editor", {remixId: props.post.id}));
127 | }}>
128 | Remix
129 |
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 |
152 |
153 | Attribution
154 |
155 | {
156 | props.post.userdata.attribution.map((attributedSource) =>
157 |
164 |
165 |
166 |
167 |
168 | {attributedSource.title}
169 |
170 | )
171 | }
172 |
173 |
174 | }
175 |
182 |
183 |
184 | : null
185 | }
186 | ;
187 | };
188 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
292 |
293 |
294 | : null
295 | }
296 |
;
297 | };
298 |
--------------------------------------------------------------------------------
/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 | Create
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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------