├── src ├── react-app-env.d.ts ├── icon.ico ├── icon.png ├── favicon.ico ├── logo.icns ├── index.css ├── context │ ├── types.tsx │ └── root.tsx ├── index.tsx ├── serviceWorker.ts └── components │ └── pages │ ├── Header.tsx │ └── Videos.tsx ├── public ├── nsfw-app-splash-screen.png ├── manifest.json ├── LICENSE └── index.html ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynamite-ready/movie-parser/HEAD/src/icon.ico -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynamite-ready/movie-parser/HEAD/src/icon.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynamite-ready/movie-parser/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/logo.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynamite-ready/movie-parser/HEAD/src/logo.icns -------------------------------------------------------------------------------- /public/nsfw-app-splash-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynamite-ready/movie-parser/HEAD/public/nsfw-app-splash-screen.png -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "NSFW Movie Parser", 3 | "name": "NSFW Movie Parser", 4 | "icons": [ 5 | { 6 | "src": "./src/icon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff", 15 | "window": { 16 | "fullscreen": false, 17 | "icon": "./src/icon.png", 18 | "min_height": 800, 19 | "min_width": 450 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # caching 12 | /cache 13 | 14 | # production 15 | /build 16 | 17 | /deploy/** 18 | 19 | # `temp` file folder 20 | /public/temp 21 | /public/dist 22 | 23 | /deploy/output 24 | 25 | # misc 26 | .DS_Store 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /public/LICENSE: -------------------------------------------------------------------------------- 1 | Raskie's NSFW Movie Parser 1.0.1 - A simple freeware AI based video parser from raskie.com (l_r_charles@hotmail.com). 2 | Please link to , if you intend to redistribute this .exe. 3 | 4 | Copyright (C) 2022 Renrick Charles (@dynamiteReady) 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details 15 | 16 | . -------------------------------------------------------------------------------- /src/context/types.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // This will be a list of strings in the following format: 4 | // [ Start Time, End Time, Is NSFW? ] 5 | export type SceneMetadataItem = string[]; 6 | 7 | // This will be a file path. 8 | export type VideoFileItem = string; 9 | 10 | // My State object contains 8 properties, 4 of them are 11 | // `useState` setters. There is no reducer. 12 | export type ContextState = { 13 | isProcessed: boolean; // Are the videos processed yet? 14 | loading: boolean; // Are we in the middle of processing vids? 15 | sceneMetadata?: SceneMetadataItem[]; // Metadata for each video. 16 | videoFiles?: VideoFileItem[]; // A list of video file paths 17 | 18 | // Basic `useState` setters for all the above properties. 19 | setIsProcessed?: React.Dispatch>; 20 | setLoading?: React.Dispatch>; 21 | setSceneMetadata?: React.Dispatch>; 22 | setVideoFiles?: React.Dispatch>; 23 | }; 24 | 25 | export type ContextProviderProps = { 26 | children: React.ReactNode; 27 | }; 28 | -------------------------------------------------------------------------------- /src/context/root.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | ContextProviderProps, 4 | ContextState, 5 | SceneMetadataItem, 6 | VideoFileItem, 7 | } from "./types"; 8 | 9 | export const RootContextObject: ContextState = { 10 | isProcessed: false, 11 | loading: false, 12 | videoFiles: [], 13 | sceneMetadata: [], 14 | }; 15 | 16 | export const RootContext = React.createContext(RootContextObject); 17 | 18 | export const RootContextProvider = ({ children }: ContextProviderProps) => { 19 | const [videoFiles, setVideoFiles] = useState([]); 20 | const [loading, setLoading] = useState(false); 21 | const [sceneMetadata, setSceneMetadata] = useState([]); 22 | const [isProcessed, setIsProcessed] = useState(false); 23 | 24 | return ( 25 | 40 | {children} 41 | 42 | ); 43 | }; 44 | 45 | export default RootContext; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nsfw-movie-parser", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "24.0.12", 7 | "@types/node": "11.13.8", 8 | "@types/react": "16.8.15", 9 | "@types/react-dom": "16.8.4", 10 | "@uifabric/fluent-theme": "^0.16.7", 11 | "nw-react-scripts": "2.1.1", 12 | "office-ui-fabric-react": "^6.174.0", 13 | "react": "^16.9.0", 14 | "react-dom": "^16.9.0", 15 | "react-router": "^5.0.1", 16 | "react-router-dom": "^5.0.1", 17 | "react-scripts": "3.0.0", 18 | "rimraf": "^3.0.0", 19 | "typescript": "3.4.5" 20 | }, 21 | "scripts": { 22 | "start": "nw-react-scripts start", 23 | "build": "nw-react-scripts build", 24 | "test": "nw-react-scripts test", 25 | "eject": "nw-react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": "nw-react-app" 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "main": "index.html", 43 | "nwBuilder": { 44 | "//": "https://github.com/nwjs-community/nw-builder", 45 | "platforms": [ 46 | "win64" 47 | ], 48 | "version": "0.60.0", 49 | "flavor": "normal", 50 | "buildDir": "./build", 51 | "cacheDir": "./cache", 52 | "macIcns": "./src/logo.icns", 53 | "winIco": "./src/icon.ico", 54 | "zip": false 55 | }, 56 | "devDependencies": { 57 | "@types/react-router": "^5.0.3", 58 | "@types/react-router-dom": "^4.3.5" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Header } from "./components/pages/Header"; 4 | import { Videos } from "./components/pages/Videos"; 5 | import { FluentCustomizations } from "@uifabric/fluent-theme"; 6 | import { Customizer, initializeIcons, mergeStyles } from "office-ui-fabric-react"; 7 | import { HashRouter, Route } from "react-router-dom"; 8 | 9 | import * as serviceWorker from "./serviceWorker"; 10 | import { RootContextProvider } from "./context/root"; 11 | 12 | const gui = require("nw.gui"); 13 | const win = gui.Window.get(); 14 | const rimraf = require("rimraf"); 15 | 16 | win.setMinimumSize(800, 450); 17 | // win.showDevTools(); 18 | 19 | // Ah... Fluent/Fabric UI... Never change... 20 | initializeIcons(); 21 | 22 | win.on("close", () => { 23 | const rootPath = `${process.cwd()}/`; 24 | const tmpDirPath = `${rootPath}temp/`; 25 | 26 | win.hide(); 27 | // Delete temp image folder when closing the application. 28 | rimraf.sync(tmpDirPath); 29 | win.close(true); 30 | }); 31 | 32 | // Global styles. 33 | mergeStyles({ 34 | selectors: { 35 | ":global(body), :global(html), :global(#root)": { 36 | margin: 0, 37 | padding: 0, 38 | height: "100vh", 39 | background: "#2d2d2d", 40 | }, 41 | }, 42 | }); 43 | 44 | ReactDOM.render( 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | , 53 | document.getElementById("root") 54 | ); 55 | 56 | serviceWorker.unregister(); 57 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Raskie's NSFW Movie Parser 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The NSFW Movie Parser 2 | 3 | An app to read a movie file, and indicate which scenes are NSFW. You can download the installer for [the Windows 10 version here](https://1drv.ms/u/s!AjqBQLCPd19igUS7zy_ENw7RN3G2). 4 | 5 | ## Using the app 6 | 7 | | [![Launch the app](https://i.imgur.com/k9xZiHuh.png)](https://i.imgur.com/k9xZiHu.png) | [![Upload a video file](https://i.imgur.com/SIhRieth.png)](https://i.imgur.com/SIhRiet.png) | [![Check the file for NSFW content!](https://i.imgur.com/L3C9CKmh.png)](https://i.imgur.com/L3C9CKm.png) | 8 | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | 9 | | 1: Launch | 2: Upload | 3: Evaluate | 10 | 11 | #### *The program is simple. After launching it, simply click the 'Upload' button (step 1). Once you've done that, use the file dialog to choose an \`.mp4\` file. After a short wait (step 2), the app will load and display a video for each scene detected in the video submitted. Then, step by step, the app will evaluate each scene, and highlight the banner under each vid to indicate it's NSFW status.* 12 | 13 | You can find more information at - https://raskie.com/post/practical-ai-autodetecting-nsfw 14 | 15 | ## 1: Dependencies 16 | 17 | These build instructions assume you are on Windows 10+. 18 | Building for OSX or Linux isn't that much harder though, but if you need help, you can raise an issue. 19 | 20 | First of, you will need to install Node JS. You can find it here - https://nodejs.org/en/ 21 | 22 | You will then need to build the following CLI programs found in the accompanying Movie Parser CLI project, 23 | found here - https://github.com/dynamite-ready/movie-parser-cli 24 | 25 | - evaluate-images.exe 26 | - process-video.exe 27 | 28 | Instructions for building and running the programs above can be found in the repo, but you will also 29 | need the following files to make the NSFW Movie Parser work 30 | 31 | - `mkvmerge.exe` (found here - https://www.videohelp.com/software/MKVToolNix) 32 | - `ResNet50_nsfw_model.pth` (found here - https://github.com/emiliantolo/pytorch_nsfw_model) 33 | 34 | ## 2: Installation 35 | 36 | Using the command line, open a prompt in the project folder, and then: 37 | 38 | ``` 39 | npm install 40 | ``` 41 | 42 | ## 3: Build 43 | 44 | You will need to build the app before you can start the program, as the build command will 'spread' the contents of the `./public` 45 | folder into the `./build` folder: 46 | 47 | ``` 48 | npm run build 49 | ``` 50 | 51 | ## 4: Start 52 | 53 | To run the app in test mode, use the following command: 54 | 55 | ``` 56 | npm start 57 | ``` 58 | 59 | This will run the application in a desktop window locally, so you can test and debug the code. 60 | 61 | ## Known bugs 62 | 63 | - When rebuilding the app to package it, always delete the `./build` folder first 64 | - On Windows especially, the `npm run build` command will flake out, if you don't run the command from a terminal with administrator privileges 65 | - When compiling a build for production, the app will choke if `win.showDevTools();` is called 66 | - The bigger the video, the longer it will take to process it. This app wasn't built for long movies -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl) 112 | .then(response => { 113 | // Ensure service worker exists, and that we really are getting a JS file. 114 | const contentType = response.headers.get('content-type'); 115 | if ( 116 | response.status === 404 || 117 | (contentType != null && contentType.indexOf('javascript') === -1) 118 | ) { 119 | // No service worker found. Probably a different app. Reload the page. 120 | navigator.serviceWorker.ready.then(registration => { 121 | registration.unregister().then(() => { 122 | window.location.reload(); 123 | }); 124 | }); 125 | } else { 126 | // Service worker found. Proceed as normal. 127 | registerValidSW(swUrl, config); 128 | } 129 | }) 130 | .catch(() => { 131 | console.log( 132 | 'No internet connection found. App is running in offline mode.' 133 | ); 134 | }); 135 | } 136 | 137 | export function unregister() { 138 | if ('serviceWorker' in navigator) { 139 | navigator.serviceWorker.ready.then(registration => { 140 | registration.unregister(); 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/components/pages/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useContext, useState } from "react"; 2 | import { CommandBar } from "office-ui-fabric-react/lib/CommandBar"; 3 | import { RootContext } from "../../context/root"; 4 | import { 5 | Spinner, 6 | SpinnerSize, 7 | Overlay, 8 | Image, 9 | IImageProps, 10 | AnimationStyles, 11 | mergeStyles, 12 | } from "office-ui-fabric-react"; 13 | import { RouteComponentProps } from "react-router-dom"; 14 | 15 | type HeaderProps = { 16 | history: RouteComponentProps["history"]; 17 | }; 18 | 19 | const rimraf = require("rimraf"); 20 | const fs = require("fs"); 21 | const process = require("process"); 22 | const childProcess = require("child_process"); 23 | const gui = require("nw.gui"); 24 | 25 | export const Header: React.FunctionComponent = ({ history }) => { 26 | const rootContext = useContext(RootContext); 27 | const $fileUpload = useRef(document.createElement("input")); 28 | const [fadeOut, setFadeOut] = useState(false); 29 | 30 | if ( 31 | !rootContext.setIsProcessed || 32 | !rootContext.setLoading || 33 | !rootContext.setSceneMetadata || 34 | !rootContext.setVideoFiles 35 | ) { 36 | return <>; 37 | } 38 | 39 | const rootPath = `${process.cwd()}/`; 40 | const tmpDirPath = `${rootPath}temp/`; 41 | 42 | // Create an empty temporary folder in the `/public` directory 43 | // if it doesn't yet exist. 44 | if (!fs.existsSync(tmpDirPath)) { 45 | fs.mkdirSync(tmpDirPath); 46 | } 47 | 48 | const openFileDialog = () => { 49 | if ($fileUpload) { 50 | $fileUpload.current.click(); 51 | } 52 | }; 53 | 54 | const processVideo = () => { 55 | if ( 56 | // Must be a better way to handle these guards. 57 | !rootContext.setIsProcessed || 58 | !rootContext.setLoading || 59 | !rootContext.setSceneMetadata 60 | ) { 61 | return; 62 | } 63 | 64 | const currentFile = $fileUpload.current.value; 65 | 66 | // Only do stuff if the file has realy changed. 67 | if (!currentFile) return; 68 | 69 | rootContext.setIsProcessed(false); 70 | 71 | // Good point at which to add a loader... 72 | rootContext.setLoading(true); 73 | rootContext.setSceneMetadata([]); 74 | history.push("/"); 75 | 76 | // Delete the temporary video folder. if left over from a 77 | // previous upload. 78 | rimraf.sync(tmpDirPath); 79 | 80 | // Apply the Fade out after a few seconds. 81 | if (!fadeOut) { 82 | setTimeout(() => { 83 | setFadeOut(true); 84 | }, 5000); 85 | } 86 | 87 | // Call the `process-video` CLI. We will use NodeJS's 88 | // `childProcess.spawn` command and it's evented API to process 89 | // the CLI response. 90 | const processVideoCommand = childProcess.spawn( 91 | `"${rootPath}dist/process-video.exe"`, 92 | [`"${currentFile}"`], 93 | { shell: true } 94 | ); 95 | 96 | // This event handler runs whenever data comes back from the `process-video` 97 | // CLI command. We deserialize the data, and then store the metadata for 98 | // the videos the CLI program has generated. These videos are initially stored 99 | // In a unnamed temporary folder. This is because the Python `SceneDetect` 100 | // module, AFAIK, doesn't have a way to chose where the files go. 101 | processVideoCommand.stdout.on("data", (data: BinaryType[]) => { 102 | try { 103 | if (!rootContext.setSceneMetadata) { 104 | return; 105 | } 106 | 107 | const outputString = data.toString(); 108 | const metadata = JSON.parse(outputString); 109 | rootContext.setSceneMetadata(metadata); // This is when the data is stored. 110 | } catch (error) { 111 | // We're merely swallowing these errors for now. 112 | // This is a hack... 113 | } 114 | }); 115 | 116 | // When the process-video CLI program has finished, first want to move the 117 | // generated videos into a named folder, and then store a path to each video 118 | // for further processing later on. 119 | processVideoCommand.on("close", (_code: number) => { 120 | try { 121 | if (!rootContext.setLoading || !rootContext.setVideoFiles) return; 122 | 123 | // Create a temp folder if one doesn't yet exist. 124 | if (!fs.existsSync(tmpDirPath)) fs.mkdirSync(tmpDirPath); 125 | 126 | // By default the vids are dumped into the `/build` folder. 127 | // Read it's contents. 128 | const tmpDir: string[] = fs.readdirSync(`${rootPath}`); 129 | 130 | tmpDir.forEach((element: string) => { 131 | // If an item inside the build folder has a filename beginning 132 | // with 'tmp', we move the file to the `/public` folder. 133 | if (element.slice(0, 3) === "tmp") { 134 | fs.renameSync( 135 | `${rootPath}${element}`, 136 | `${tmpDirPath}${element}-${new Date().getTime()}` 137 | ); 138 | } 139 | }); 140 | 141 | // Store a list of all the videos that have been created. 142 | rootContext.setVideoFiles(fs.readdirSync(tmpDirPath)); 143 | 144 | // Change page... 145 | history.push("/videos"); 146 | 147 | // The task is done, we can remove the loading modal. 148 | rootContext.setLoading(false); 149 | } catch (error) {} 150 | }); 151 | }; 152 | 153 | // The euphoric joy of a frontend component library... 154 | const menuItems = [ 155 | { 156 | key: "upload", 157 | name: "Upload", 158 | onClick: openFileDialog, 159 | ["data-automation-id"]: "upload-button", 160 | }, 161 | ]; 162 | 163 | const farMenuItems = [ 164 | { 165 | key: "about", 166 | name: "About", 167 | iconOnly: true, 168 | onClick: () => { 169 | gui.Shell.openExternal( 170 | "https://raskie.com/post/practical-ai-autodetecting-nsfw" 171 | ); 172 | }, 173 | ["data-automation-id"]: "about-link", 174 | menuIconProps: { 175 | iconName: "Help", 176 | }, 177 | }, 178 | ]; 179 | 180 | // A ropey splash image... 181 | const imageProps: Partial = { 182 | src: "./nsfw-app-splash-screen.png", 183 | styles: { 184 | root: { 185 | position: "absolute", 186 | left: "50%", 187 | top: "50%", 188 | transform: "translate(-50%, -50%)", 189 | }, 190 | }, 191 | }; 192 | 193 | // A style to fade out the image. 194 | const fadeOutElement = mergeStyles(AnimationStyles.fadeOut400); 195 | 196 | return ( 197 |
198 |
205 | {rootContext.loading && ( 206 | 214 | 223 | 224 | )} 225 | 232 |
233 | 246 | 247 |
248 | ); 249 | }; 250 | -------------------------------------------------------------------------------- /src/components/pages/Videos.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollablePane } from "office-ui-fabric-react"; 2 | import React, { useContext } from "react"; 3 | import RootContext from "../../context/root"; 4 | import { SceneMetadataItem, VideoFileItem } from "../../context/types"; 5 | 6 | const fs = require("fs"); 7 | const childProcess = require("child_process"); 8 | const rimraf = require("rimraf"); 9 | 10 | const videoElementWrapperStyle = { 11 | display: "inline-block", 12 | width: "33.333%", 13 | margin: "0px", 14 | }; 15 | 16 | const videoElementStyle = { 17 | width: "100%", 18 | }; 19 | 20 | const textPanelStyle = { 21 | width: "100%", 22 | padding: "10px 0px", 23 | background: "#444444", 24 | color: "white", 25 | fontSize: "10px", 26 | fontFamily: "arial", 27 | }; 28 | 29 | const textStyle = { 30 | marginLeft: "10px", 31 | }; 32 | 33 | export const Videos: React.FunctionComponent = (_props) => { 34 | const rootContext = useContext(RootContext); 35 | const rootPath = `${process.cwd()}/`; 36 | const tmpDirPath = `${rootPath}temp/`; 37 | 38 | if ( 39 | !rootContext.setIsProcessed || 40 | !rootContext.setLoading || 41 | !rootContext.setSceneMetadata || 42 | !rootContext.setVideoFiles 43 | ) { 44 | return <>; 45 | } 46 | 47 | // A bit of a cheeky hack this. I want to make sure that 48 | // all every file in the list of images captured from a video file 49 | // has been created. I am prepared to wait (hence `existsSync`). 50 | // If any single item in the list has not yet been found to exist, 51 | // then check the list again, until we're sure that every image frame has 52 | // been generated. 53 | const checkAllFilesExist = (files: string[]) => { 54 | files.forEach((item: string) => { 55 | if (!fs.existsSync(item)) { 56 | checkAllFilesExist(files); 57 | } 58 | }); 59 | }; 60 | 61 | // After evaluating a video, update the metadata item to indicate is it's 62 | // mucky or not. 63 | const updateMetadata = ( 64 | metadata: SceneMetadataItem[] | undefined, 65 | sceneIndex: number, 66 | imageResponse: string 67 | ) => { 68 | return metadata 69 | ? [ 70 | ...metadata.slice(0, sceneIndex), 71 | [...metadata[sceneIndex], Boolean(JSON.parse(imageResponse) > -0.5)], 72 | ...metadata.slice(sceneIndex + 1), 73 | ] 74 | : []; 75 | }; 76 | 77 | // The next two functions are just for convenience. 78 | const getProcessVideoCommand = (arg: string[]) => { 79 | return `"${arg[0]}dist/process-video.exe" "${arg[1]}${arg[2]}" --ipath "${arg[3]}" --images`; 80 | }; 81 | 82 | const getEvaluateImagesCommand = (arg: string[]) => { 83 | return `"${arg[0]}dist/evaluate-images/evaluate-images.exe" "${arg[1]}" --model "${arg[0]}dist/ResNet50_nsfw_model.pth"`; 84 | }; 85 | 86 | if (!rootContext.isProcessed) { 87 | rootContext.setIsProcessed(true); 88 | 89 | const evaluateScene = ( 90 | sceneIndex: number, 91 | updatedMeta: SceneMetadataItem[] | null 92 | ) => { 93 | if (!rootContext.videoFiles) return <>; 94 | 95 | // This temporary folder is the key to the whole program. 96 | // For each individual video file split out from the upload, 97 | // I will capture a sample of images from it. Those sampled images are 98 | // then store in this folder. 99 | // Once they are all in place, we can then use the evaluate-images CLI program to 100 | // check each individial image for NSFW content. 101 | const tmpImageDirPath = `${tmpDirPath}${rootContext.videoFiles[sceneIndex]}-images/`; 102 | 103 | if (!fs.existsSync(tmpImageDirPath)) { 104 | childProcess.exec( 105 | getProcessVideoCommand([ 106 | rootPath, 107 | tmpDirPath, 108 | rootContext.videoFiles[sceneIndex], 109 | tmpImageDirPath, 110 | ]), 111 | (_err: NodeJS.ErrnoException, filelist: string) => { 112 | try { 113 | const imageList = JSON.parse(filelist); 114 | 115 | // The way this is done is suboptimal. 116 | // It mostly because the evaluate-images CLI program currently only 117 | // works with folders of images. 118 | checkAllFilesExist(imageList); 119 | 120 | // Now we're ready to check our temporary image folder for pr0ns. 121 | childProcess.exec( 122 | getEvaluateImagesCommand([rootPath, tmpImageDirPath]), 123 | (_err: NodeJS.ErrnoException, nsfwEstimate: string) => { 124 | // Once the command is complete, we can delete the temp folder. 125 | rimraf.sync(tmpImageDirPath); 126 | 127 | if (!rootContext.setSceneMetadata || !rootContext.videoFiles) 128 | return <>; 129 | 130 | const metadata: SceneMetadataItem[] | undefined = updatedMeta 131 | ? updatedMeta 132 | : rootContext.sceneMetadata; 133 | // Update the metadata with opinion of if a given clip is NSFW. 134 | const updatedMetadata: SceneMetadataItem[] = updateMetadata( 135 | metadata, 136 | sceneIndex, 137 | nsfwEstimate 138 | ) as SceneMetadataItem[]; 139 | 140 | // Store the updated metadata list. 141 | rootContext.setSceneMetadata(updatedMetadata); 142 | 143 | if (sceneIndex < rootContext.videoFiles.length) { 144 | // While there are video scene clips to check for their NSFW status, 145 | // recursively run the evaluate-images process on each successive video. 146 | evaluateScene(sceneIndex + 1, updatedMetadata); 147 | } 148 | } 149 | ); 150 | } catch (e) {} 151 | } 152 | ); 153 | } 154 | }; 155 | 156 | // Evaluate the first video. 157 | evaluateScene(0, null); 158 | } 159 | 160 | return rootContext.sceneMetadata ? ( 161 | 162 | {rootContext.videoFiles && rootContext.videoFiles.length 163 | ? rootContext.videoFiles.map((item: VideoFileItem, index: number) => { 164 | const isNSFW = 165 | rootContext.sceneMetadata && 166 | rootContext.sceneMetadata[index] && 167 | rootContext.sceneMetadata[index][2] !== undefined && 168 | rootContext.sceneMetadata[index][2] !== null 169 | ? rootContext.sceneMetadata[index][2] 170 | : null; 171 | 172 | return ( 173 |
174 | {/* Display our video clips */} 175 | 178 |
192 | {rootContext.sceneMetadata && 193 | rootContext.sceneMetadata[index] && ( 194 | 195 | {/* This is where we display the metadata for each video */} 196 | From: {rootContext.sceneMetadata[index][0]}, To:{" "} 197 | {rootContext.sceneMetadata[index][1]} 198 | 199 | )} 200 | {isNSFW && ( 201 | 209 | NSFW! 210 | 211 | )} 212 |
213 |
214 | ); 215 | }) 216 | : []} 217 |
218 | ) : ( 219 | <> 220 | ); 221 | }; 222 | --------------------------------------------------------------------------------