Днес изграждаме приложение за камера, което използва WebGL за предварителен преглед на живо, наситеност и филтри за яркост. Това е като Instagram, но не.
Днес изграждаме приложение за камера с филтри за наситеност и яркост за преглед на живо. Това е като Instagram, но не. Няма бутон „Направете снимка“. Трябва да направите екранна снимка. Но филтри!
Можете да го превърнете в Instagram с тези полезни WebGL шейдъри, които @stoffern изгражда. О, да, ние използваме WebGL, за да манипулираме изхода на камерата
В този урок ще научите:
- как да използвате камерата с react-native-camera
- как се прави WebGL с gl-react
- рудиментарно откриване на жестове с PanResponder
Създаваме приложението в пет стъпки. Можете да видите неговия „завършен източник в 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.