Днес изграждаме приложение за камера, което използва WebGL за предварителен преглед на живо, наситеност и филтри за яркост. Това е като Instagram, но не.

Днес изграждаме приложение за камера с филтри за наситеност и яркост за преглед на живо. Това е като Instagram, но не. Няма бутон „Направете снимка“. Трябва да направите екранна снимка. Но филтри!

Можете да го превърнете в Instagram с тези полезни WebGL шейдъри, които @stoffern изгражда. О, да, ние използваме WebGL, за да манипулираме изхода на камерата

В този урок ще научите:

Създаваме приложението в пет стъпки. Можете да видите неговия „завършен източник в Github“.

Искате ли да създавате ново приложение на всеки две седмици? Абониране по имейл.

Стъпка 0: Подготвителна работа

След като стартираме $ react-native init LiveInstagram, трябва да направим малко подготовка. Мисля, че това ще бъде стандартният подход занапред... може би трябва да направя шаблон

Но шаблоните са капан. За поддържащия.

Ето какво правите:

1. Инсталирайте Shoutem UI инструменти с $ react-native install @shoutem/ui. Ще го използваме леко в този урок, но свикнах да го има 🙂

2. Почистете index.ios.js. Когато сте готови, трябва да изглежда така:

// index.ios.js
import React, { Component } from 'react';
import {
  AppRegistry,
} from 'react-native';

import App from './src/App';

AppRegistry.registerComponent('LiveInstagram', () => App);

Можете да направите същото с index.android.js, ако предпочитате да работите на Android. Фокусирам се върху iOS, защото нямам устройство с Android.

3. Създайте src/ директория за целия ни код.

4. Направете App.js файл с нашия базов компонент. Нещо подобно за сега:

// src/App.js

import React, { Component } from 'react';
import { Screen } from '@shoutem/ui';

export default class App extends Component {
    render() {
        <Screen />
    }
}

Ползата от преместването на целия ни код в src/App и изкормването index.*.js е, че е по-лесно да споделяме код между двете платформи. Нашето приложение LiveInstagram не следва конвенциите на потребителския интерфейс на нито една платформа, така че можем да използваме един и същ код и за двете.

Стъпка 1: Приложение за камера

Започваме с основно приложение за камера. Иска разрешения за камерата, показва преглед на живо на вашата камера.

Благодарение на страхотната работа на @lwansbrough върху react-native-camera, тази част е проста. След „фиаското на Firebase миналата седмица“, влязох в този проект, очаквайки най-лошото.

Нещата с камерата бяха най-лесни.

1. Инсталирайте с $ react-native install react-native-camera

2. Настройте запитване за разрешения вътре в Info.plist.

./ios/LiveInstagram/Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
  ...
    <key>NSCameraUsageDescription</key>
    <string>We need your beautiful camera</string>
    <key>NSPhotoLibraryUsageDescription</key>
    <string>We're only saving to temp, promise</string>
  </dict>
</plist>

Имаме нужда от разрешение за използване на камерата и разрешение за запазване на снимки в библиотеката. Ние не запазваме никакви снимки, така че искането за разрешения за библиотека е гадно, но са ни необходими за достъп до temp.

react-native-camera позволяваше правенето на снимки направо в паметта, но това беше отхвърлено. Не знам защо.

3. Изобразете изгледа на камерата в App

// src/App.js
import Camera from 'react-native-camera';

export default class App extends Component {
    render() {
        return (
            <Screen>
                <Camera style={{flex: 1}}
                        ref={cam => this.camera=cam}
                        aspect={Camera.constants.Aspect.fill}>
                    
                </Camera>
            </Screen>
        );
    }
}

Добавяме <Camera> към нашия основен метод за изобразяване. flex: 1 го кара да запълва екрана, съотношението fill гарантира, че изглежда добре. Ще използваме this.camera по-късно за достъп до екземпляра на камерата и заснемане на снимки.

Стартирайте приложението на телефона си с XCode и ще видите какво може да види телефонът ви. Обичам колко лесно беше. Благодаря @lwansbrough.

Стъпка 2: Изобразете WebGL тестов компонент

Сега, когато имаме камера, трябва да настроим компонента WebGL. Той ще наслагва изгледа на камерата с изобразен от GPU изглед с променена яркост и наситеност.

Използваме gl-react v3 alpha от @gre и нямам представа как работи. Знам, че това е API на WebGL, но той работи с естествен код, така че всъщност не може да бъде WebGL.

Щастлив съм, че абстракцията е толкова добра, че не е нужно да знам как работят нещата отдолу ☺️

1. Трябва да инсталираме gl-react и gl-react-native. Уверете се, че имате версията next, защото старата 2.x не работи добре. v3 е пълно пренаписване.

$ react-native install gl-react@next
$ react-native install gl-react-native@next

2. Нашият WebGL Surface трябва да знае колко е голям, така че трябва сами да следим width и height. Можем да направим това с onLayout обратно извикване на нашия Screen компонент и някакво локално състояние.

// src/App.js

export default class App extends Component {
    state = {
        width: null,
        height: null
    }

    onLayout = (event) => {
        const { width, height } = event.nativeEvent.layout;

        this.setState({
            width,
            height
        });
    }
    
    render() {
        const { width, height } = this.state;

        if (width && height) {
            return (
                <Screen onLayout={this.onLayout}>
                        // ...
                </Screen>
            );
       }else{
            return (
                <Screen onLayout={this.onLayout} />
            );
        }
    }
}

onLayout се извиква всеки път, когато приложението ни пренастройва оформлението. Използваме обратното извикване, за да прочетем новите width и height и да ги запазим в this.state.

3. Рендираме WebGL Surface вътре в компонента Camera. Това ни позволява да наслагваме изгледа на камерата, което е страхотно. Изобразяваме непрозрачни неща, така че камерата така или иначе да е невидима, което е разточително.

// src/App.js

import { Surface } from "gl-react-native";

import Saturate from './Saturate';

export default class App extends Component {
    // ...
    
    render() {
        // ...
        
        const filter = {
        contrast: 1,
        saturation: 1,
        brightness: 1
        }
        
        <Camera style={{flex: 1}}
            ref={cam => this.camera=cam}
            aspect={Camera.constants.Aspect.fill}>

        <Surface style={{ width, height }}>
            <Saturate {...filter}>
                {{ uri: "https://i.imgur.com/uTP9Xfr.jpg" }}
            </Saturate>
        </Surface>

    </Camera>
    
    // ...
    }
}

Можете да мислите за Surface като за компонент на платното. Това е регион, в който изобразявате WebGL възли. Всяка повърхност се нуждае от поне един дъщерен възел.

Saturate е Node компонент. Той изобразява изображение и коригира неговите contrast, saturation и brightness.

4. Кодът за Saturate идва от gl-react example, защото не разбирам достатъчно кода на GL шейдъра, за да напиша свой собствен.

// src/Saturation.js
//@flow
import React, { Component } from "react";
import { Shaders, Node, GLSL } from "gl-react";

const shaders = Shaders.create({
  Saturate: {
    frag: GLSL`
precision highp float;
varying vec2 uv;
uniform sampler2D t;
uniform float contrast, saturation, brightness;
const vec3 L = vec3(0.2125, 0.7154, 0.0721);
void main() {
  vec4 c = texture2D(t, uv);
    vec3 brt = c.rgb * brightness;
    gl_FragColor = vec4(mix(
    vec3(0.5),
    mix(vec3(dot(brt, L)), brt, saturation),
    contrast), c.a);
}
`
  }
});

const Saturate = ({ contrast, saturation, brightness, children }) => (
    <Node shader={shaders.Saturate}
          uniforms={{ contrast, saturation, brightness, t: children }} />
);

export default Saturate;

Ето какво разбирам: За gl-react дефинираме шейдърите статично. Всеки идва като GLSL езиков блок и влиза в голям Shaders речник.

Все още не разбирам езика GLSL. Знам, че main() се извиква на всеки пиксел, знам, че този код работи на графичния процесор и знам, че изглежда много като C код. Освен това изглежда, че се основава на векторна математика и матрична композиция.

Мисля, че задаваме основното си изображение като текстура. texture2D със сигурност предполага толкова много.

Самият компонент Saturate изобразява Node, който приема shader и нещо, наречено униформи. Не съм сигурен какви са те, но изглежда, че могат да бъдат аргументи за GLSL кода.

Стъпка 3: Подайте данни от камерата в WebGL изглед

Страхотно, имаме изобразено чрез WebGL статично изображение с невероятна наситеност и изглед на камера на живо, скрит отдолу. Ако това звучи лошо от гледна точка на ефективността, изчакайте, докато видите какво ще се случи след това

Правим снимка на всеки 5 милисекунди, запазваме я на временно място и актуализираме WebGL изгледа.

Експериментално измислих 5ms. Изглежда гладко и с някои промени в качеството на изображението предпазва приложението от срив за почти цяла минута. Да, приложението се срива. Да, лошо е. Не, не знам [все още] как да го поправя. Може би някой ще ми каже в коментарите

1. Стартираме таймер в onLayout, защото искаме да сме сигурни, че this.camera съществува. Рендираме Camera само след като имаме width/height, запомнете.

// src/App.js
class App {
    // ...
    
    onLayout = (event) => {
        // ..
        this.start();
    }

    refreshPic = () => {
        // pic taking
    }

    start() {
        this.timer = setInterval(() => this.refreshPic(),
                                 5);
    }

    onComponentWillUnmount() {
        clearInterval(this.timer);
    }

onLayout извиква start, което задава интервал на всеки 5 милисекунди. Уверяваме се, че спираме интервала в onComponentWillUnmount.

2. Благодарение на @lwansbrough, правенето на снимка е толкова лесно, колкото беше визуализацията на камера.

// src/App.js

class App {
    // ...
    refreshPic = () => {
        this.camera
            .capture({
                target: Camera.constants.CaptureTarget.temp,
                jpegQuality: 70
            })
            .then(data => this.setState({
                path: data.path
            }))
            .catch(err => console.error(err));
    }

Извикваме .capture на this.camera, казваме му да използва 70% jpeg качество и да запазва изображения на временно място. Запазването е необходимо, защото директното към паметта изчезна в скорошна версия на react-native-camera. Това е частта, която изисква разрешения за достъп до библиотека в iOS.

Когато обещанието се разреши, ние получаваме път към локалното изображение и го записваме в локално състояние. Ако има грешка, плачем.

3. Визуализацията на нашата камера е невидима. Изключването му би спестило ресурси и дори може да убеди приложението ни да не се срине. Но не можах да разбера как да направя това. Намаляването на качеството на продукцията е следващото най-добро нещо.

Добавете captureQuality към <Camera> в изобразяване. Като този:

// src/App.js

class App {
    // ...
    <Camera style={{flex: 1}}
            ref={cam => this.camera=cam}
            captureQuality={Camera.constants.CaptureQuality["720p"]}
            aspect={Camera.constants.Aspect.fill}>

Можете да си поиграете със стойностите в константата filter, за да видите как влияят на изображението.

Стъпка 4: Основно разпознаване на жестове

React Native идва с вграден клас за откриване на потребителски жестове — PanResponder. Не знам защо се казва така.

Можем да го използваме, за да открием в каква посока потребителят плъзга по екрана. API, който предлага, е на доста ниско ниво, така че има известна ръчна работа, свързана с разгадаването на намеренията на потребителя.

Основите изглеждат така:

1. Стартирайте екземпляр на PanResponder в componentWillMount.

// src/App.js

class App {
    // ...
    
    componentWillMount() {
        this._panResponder = PanResponder.create({
            onMoveShouldSetResponderCapture: () => true,
            onMoveShouldSetPanResponderCapture: () => true,

            onPanResponderGrant: (e, {x0, y0}) => {
                // start gesture
            },

            onPanResponderMove: (e, {dx, dy}) => {
                // gesture progress
            },

            onPanResponderRelease: (ev, {vx, vy}) => {
                // gesture complete
            }
        });
    }

Създаваме нов екземпляр PanResponder и го присвояваме на this._panResponder. За да работи, ние дефинираме куп обратни извиквания.

onPanResponderGrant се извиква, когато започне жест, ще го използваме, за да инициализираме нашия код за проследяване. onPanResponderMove е мястото, където ще проследяваме движението и ще актуализираме наситеността/яркостта, а onPanResponderRelease е мястото, където можем да дадем на потребителя някаква обратна връзка за „свършеното“. Няма да имаме нужда от това.

2. Прикрепяме нашия панорамен отговор към основния изглед на приложението по следния начин:

// src/App.js

class App {
    // ...
    render() {
        const { width, height } = this.state;

        if (width && height) {
            return (
                <Screen onLayout={this.onLayout}
                        {...this._panResponder.panHandlers}>
                   // ..
        }else{
            return (
                <Screen onLayout={this.onLayout} />
            );
        }
    }
}

Това е тази {...this._panResponder.panHandlers} част. Очаквам, че можете да добавите разпознаване на жестове като това към всеки елемент на React Native. Чисто, а?

Стъпка 5: Контролирайте наситеността/яркостта чрез жестове

Време е да окабелим всичко. Ще преместим filter в локално състояние и ще го манипулираме в нашия пан манипулатор.

1. Преместваме filter в местно състояние по този начин

// src/App.js

class App {
    state = {
        width: null,
        height: null,
        path: "https://i.imgur.com/uTP9Xfr.jpg",
        contrast: 1,
        brightness: 1,
        saturation: 1
    }

        // ...
        
        render() {
        const { width, height, brightness, contrast, saturation } = this.state;

        const filter = {
            brightness,
            contrast,
            saturation
        }
        
        // ..
    }
}

Тази част беше бърза. Вместо да използваме магически стойности в const filter =, ние ги изваждаме от this.state.

2. Инициализираме началното си състояние, когато жестът започне. Ще използваме линейни скали D3, за да ни помогнат с изчисленията на „колко е плъзнал потребителят и какво означава това за яркостта“.

Стартирайте $ react-native install d3-scale. Няма нужда от цялата D3 библиотека, везните ще свършат работа.

Можете да мислите за линейна скала като за линейна функция от математиката в средното училище. Той преобразува стойности от домейн към стойности в диапазон.

// src/App.js

import { scaleLinear } from 'd3-scale';

class App {
    // ...
    dragScaleX = scaleLinear()
    dragScaleY = scaleLinear()

    componentWillMount() {
        // ...
            onPanResponderGrant: (e, {x0, y0}) => {
                const { width, height } = this.state;

                this.dragScaleX
                    .domain([-x0, width-x0])
                    .range([-1, 1]);

                this.dragScaleY
                    .domain([-y0, height-y0])
                    .range([1, -1]);
            },
            
            // ...
        });
    }

Когато започне жест, ние инициализираме нашите dragScaleX и dragScaleY с нов домейн и диапазон. range преминава от -1 към 1, защото го добавяме към стойността по подразбиране 1. 0 до 2 трябва да ни даде пълен диапазон за наситеност и яркост.

domain е по-сложен. Искаме екранът да представя пълна гама. По целия път нагоре яркостта е 2, по целия път надолу е 0, средата е 1.

Но получаваме само разстояние на плъзгане вътре в onPanResponderMove, така че трябва да зададем нашите домейни въз основа на началната позиция. Ако започнем от x0, това означава, че ще стигнем до левия край на екрана, когато плъзгаме в отрицателна посока с цели x0. Това е -x0. В другата посока имаме width-x0 пространство преди да ударим ръба.

Това прави нашия домейн [-x0, width-x0]. По същия начин за вертикалната ос: [-y0, height-y0].

3. Използваме тези скали, за да променим яркостта и наситеността в onPanResponderMove. Като този:

// src/App.js

import { scaleLinear } from 'd3-scale';

class App {
    // ...

    componentWillMount() {
        // ...
            onPanResponderMove: (e, {dx, dy}) => {
                this.setState({
                    saturation: 1 + this.dragScaleX(dx),
                    brightness: 1 + this.dragScaleY(dy)
                });
            },
            
            // ...
        });
    }

Взимаме разстоянието на плъзгане по двете оси, превеждаме го в диапазона [-1, 1] с помощта на скалите и актуализираме локалното състояние с this.setState.

Това задейства повторно изобразяване, актуализира WebGL изгледа и показва на потребителя актуализиран преглед. Достатъчно бърз, за ​​да се почувства отзивчив.

Искате ли да създавате ново приложение на всеки две седмици? Абониране по имейл.

Първоначално публикувано в school.shoutem.com.