利用NDI 6 SDK实现Android端视频接收以及手柄控制

摘要:本文通过使用最新版的“NDI 6 SDK (Android)”,实现了在Android手机端显示监看局域网内NDI视频流的功能,并通过外接Android手柄(蓝牙或有线)与Android端NDI监看相结合,初步实现了手柄对局域网里设备的控制功能。

关键字:NDI 6,JNI,NDK,手柄控制器,大模型;


一、前言

    距离上次写NDI开发的文章已经过去了四年,期间由于别的项目的原因,几乎没怎么碰Android开发和NDI,当时还是NDI 4 SDK的开发,现在已经到NDI 6了。利用NDI实现Android端的视频发送

    开发的目的很简单,完成4年前的目标-Android NDI接收端的开发,有个影视机械臂特拍的开发项目,想通过NDI结合Android手柄,做机器人拍摄的实时控制,相信很多人也跟我有差不多的想法,并且都已经实现了。

    本人开发能力有限,感谢这个时代,感谢所有免费提供大模型API的公司,豆包、通义千问、文心一言,让我的开发变的很顺利,也重新了解了之前一直不怎么熟悉的JNI,接下来我将从头开始介绍整个项目的搭建和编写过程。


二、NDI动态库的引入(加了很多自己的理解,已经懂的可以跳过)

2.1、NewTek官网去下载NDI 6 SDK(Android)

    下载完成后解压,目录结构如下图,先看下说明书,了解下NDI 6一些新特性,以及Android端的一些改变,终于不用打开和管理“NsdManager”网络发现服务了!


NDI 6 SDK(Android)目录结构


NDI 6 SDK里Android开发说明

2.2、新建一个Android C++的工程

    并将NDI 6 SDK(Android)根目录下“Include”和“Lib”两个目录拷贝到Android C++工程文件的cpp文件目录里去,include目录下放的都是C++头文件,Lib目录下放的是对应平台的动态库“libndi.so”文件,头文件至关重要,建议大家根据自己的开发需求仔细去看一下,头文件里最终要的文件是“Processing.NDI.Lib”,该头文件其实是把NDI动态库的API暴露给用户了,并且包含了(include)跟自己在一个目录下的其他头文件,所有NDI的头文件在针对各类平台的API命名和调用方法几乎都一样,唯一不一样的只有编译方式。

    在“Processing.NDI.Lib”头文件里,#pragma once表示只需要包含一次,不需要重复被包含,#ifdef表示检查宏定义是否被使用,如果被使用,则进去#ifdef~#endif里面的区域,ifdef __APPLE__,__cplusplus,__APPLE__是针对不同的编译平台,“Processing.NDI.Lib”头文件里的判定流程如下:


-》判定是否是静态库(PROCESSINGNDILIB_STATIC)-》不是

-》判断编译器是否是Win32平台(_WIN32)-》不是

-》判读是都是苹果平台(__APPLE__)-》不是-》定义库名(NDILIB_LIBRARY_NAME),对应的动态库文件是“libndi.so.6”

-》判断是都是C++编译-》不是-》定义库API(PROCESSINGNDILIB_API)的默认值

-》关联各种头文件,如Processing.NDI.structs.h,Processing.NDI.compat.h,Processing.NDI.Find.h,Processing.NDI.Recv.h等等,这些头文件里都暴露了一些API,同时也定义了一些数据结构和枚举

-》在有些define后面,跟着__attribute((visibility("default"))),它是GNU扩展属性,用于控制符号可见性(都可见),符号可见性决定了动态链接库(如.so文件)中的符号是否可以从其他模块访问(暴露出去了)


Processing.NDI.Lib.h头文件说明

    Processing.NDI.structs.h文件中的结构如下


-》定义了NDI_LIB_FourCC,注意里面写的结构,此处书写结构了#define没用缩写结构,我理解的是NDI_LIB_FourCC应该是被重新定义了

-》定义了NDIlib_frame_type_e(帧内数据类型,视频1,音频2,元数据3,错误4)

-》定义了NDIlib_FourCC_video_type_e,即4位色彩空间类型(UYVA,UYVY等等)

-》定义了NDIlib_FourCC_audio_type_e,音频结构

-》定义NDIlib_frame_format_type_e,隔行或者逐行

-》定义了时间码和时间戳的最大数值NDIlib_send_timecode_synthesize,NDIlib_recv_timestamp_undefined

-》定义了NDIlib_source_t,网络中有效的NDI信号的数据结构

-》定义了对视频帧的描述信息的数据结构,NDIlib_video_frame_v2_t,如长宽,色彩空间,制式等

-》定义了音频、元数据、Tally等的描述信息的数据结构,NDIlib_audio_frame_v2_t,NDIlib_audio_frame_v3_t,NDIlib_metadata_frame_t,NDIlib_tally_t


    Processing.NDI.compat.h文件中的结构如下


-》将<stdbool.h>和<stdint.h>两个头文件包含进去了

-》定义了“INFINITE”的值,不清楚有啥用


2.3、NDI 6 SDK (Android)\Examples\C++目录下,一些例子参考说明

    例子本身是用C++写的,而这次NDI Android端的开发,就是要通过JNI,将C++代码转换成java代码,因此参考官方给的例子是很重要的一步,本次我参考了NDIlib_Find和NDIlib_Recv两个示例,其实NDIlib_Recv就是高级版的NDIlib_Find,因此只要搞懂NDIlib_Recv这个示例就行了。

    示例文件里,无一例外的都是先加载了“Processing.NDI.Lib”头文件,,然后再引用了动态库,C++里的动态库是dll形式的,而Android里的动态库是so形式了,Android的动态库如何关联,后面会介绍。

NDIlib_Recv.cpp的大致流程如下:(示例就是这么简单,跟NDI 4简直一毛一样)


-》NDI初始化,NDIlib_initialize(),这个初始化函数,是Processing.NDI.Lib.h头文件里开放的API所包含的

-》创建Find实例-》等待5秒,并获取信号源,这些和Find里面的操作是一样的

-》创建一个接收的实例,NDIlib_recv_create_v3(),开放的API,这个实例没有传入参数,但是在前面Find实例的时候,要确保发现的NDI数量(p_sources)要大于0才会创建这个接收的实例

-》将接收到的p_sources中的第一个信号源,连接到接收的实例(pNDI_recv),使用的是开放API函数NDIlib_recv_connect,第几个信号源自己可以选,示例里给了是第一个

-》毁掉Find实例,当传参给p_sources时,它已经没用了

-》这个实例里使用std::chrono这个时间命名空间,让视频运行5分钟,在这5分钟内,要让它显示出来

-》创建视频、音频帧的描述对象实例NDIlib_video_frame_v2_t,NDIlib_audio_frame_v2_t

-》使用NDIlib_recv_capture_v2函数(API暴露出来的),将接收实例中收到的信号,传递给video_frame和audio_frame,这个函数会返回一个FrameType,音视频是分包的

-》通过switch判定,当收到的包确定是视频包时(NDIlib_frame_type_video),打印视频的长宽,并释放掉(NDIlib_recv_free_video_v2)

-》过了5分钟,毁掉Recv实例,毁掉NDI库,程序释放,并结束

NDIlib_Recv.cpp的头部信息

三、Android工程文件里的配置

3.1、回到之前建立的Android工程文件里,需要重新写一下“CmakeLists.txt”文件

    将NDI的动态库文件关联进去,只要关联的,才能通过JNI转换,才能在编译的时候将它加进去,Cmake的指令说明如下图,仅供参考。


CmakeLists.txt文件的指令说明

3.2、修改AndroidManifest.xml的设置

    其实就是Activity界面加载时的一些权限和样式的说明,权限和样式的需求,可以在AndroidManifest.xml里改,也可以进了Activity里动态的修改,在xml里更方便简单,因此我都是在AndroidManifest.xml里修改的权限。

    首先是网络的一些访问权限,只要是涉及到Internet的,都开了,其他权限根据自己项目需求适当打开,其次是显示页面的横竖显示,我这次开发是使用的横屏,为了更好的适配手柄,我把横屏又倒过来了。

最后是设置页面显示为全屏,推荐在“style.xml”里修改,有些项目里也叫“theme.xml”


AndroidManifest.xml里的网络权限


屏幕方向reverseLandscape


theme.xml里的文件配置

四、Android上NDI Recv开发流程的实现

4.1、确定页面样式

    本人使用的页面及控件都是Android自带的,以实现功能为主,美观为次要,页面里主要包含以下控件:


Spinner控件:下拉菜单,显示局域网里的NDI流,并将这些NDI流的名字放入进去,当下拉选择NDI流的时候,可以切换NDI源的显示;

ImageView控件:NDI视频流显示的页面,在所有控件的最下层;

TextView控件:打印手柄控制案件的一些信息,方便调试;

Button控件:定义了“机器人轨迹复现”的按钮,按下去后,将在UDP里发送对应的控制信息;

Switch控件:体感控制的开关,也是对应机器人控制那块的功能,后面有机会再单独写一篇,介绍六轴工业机器人体感控制的方法;


NDI接收端页面样式

4.2、确定NDI流软件内接收及显示的流程

1.在Java端,onCreate内,对所有控件进行初始化,包括ImageView,TextView,Spinner,Switch,Button,Bitmap代码如下:


onCreate中初始化的内容

2.设置按钮(Button)和下拉框(Spinner)的监听事件,按钮的监听事件就是改变一个bool值的状态,下拉框的监听事件为了重新连接NDI源:


设置按钮和下拉框的监听事件

3.启用UDP服务,封装到一个方法里,名为“startUdp()”;


4.建立Thread线程“findNDI”,并局域网里发现的NDI信号源,放置到Spinner控件内:


findNDI线程

5.建立NDI视频流捕获和显示的线程,该线程里有个“SetUpdateImg()”的方法,是一个Handler的委托,里面循环去捕获NDI视频流,并发送UDP数据:


captureLayout更新线程

6.在SetUpdateImag()方法里,做两件事,分别是不停的获取NDI视频流,以及开定时器每隔40ms在画布上显示,为什么不把两件事件并成一件事情去做,因为NDI在接收时解码再加传递给JAVA是一件比较耗时的工作,因此我把解码和传递单独再开了个线程运行。

    "StartRecvCapture"这个方法就是C++里接收NDI信号,并传递给JAVA端的方法,“sendString”对应的是要发送的Udp数据,updateRunnable是为了每隔40ms将NDI信号显示到ImageView上。


SetUpdateImag()方法

7.updateRunnable方法如下,将StartRecvCapture返回的int数组,放入Bitmap里,再把Bitmap投影到ImageView上。


updateRunnable()方法

4.3、C++端软件的实现方法以及JNI接口的心得

    不得不说发明JNI的人真的是太伟大了,此次涉及到JNI开发的主要有3个方法,分别是FindNDI(搜寻NDI流),CreateRecvNDI(选择NDI源),StartRecvCapture(接收NDI流),下面就这三个方法做详细介绍。

关于NDI的三个方法

1.FindNDI方法:

    JAVA端,输入参数无,返回参数String数组;

    C++端,输入参数JNIEnv(JNI的环境,里面对应了各种JNI方法),jobject(调用这个方法的对象,此处是“MainActivity”,这就是面向对象编程的优势),返回参数jobjectArray,对应的是Java端的String数组。

    JNI开发中,不管是C++端传入给JAVA端,还是JAVA端传递给C++端,都需要通过JNI来进行转换后传递,传递过程中数据格式的改变,方法的调用和获取,JNIEnv里提供了丰富了接口,在C++端我用的FindNDI方法,参考的是NDI SDK里FindNDI.cpp文件,稍做些修改就能用。

FindNDI具体实现方式

2.CreateRecvNDI方法:

    JAVA端,输入参数int,返回参数bool值,int表示选第几个NDI信号源,返回bool表示切换信号是否成功;

    C++端,输入参数JNIEnv,jobject(MainActivity),jint(对应JAVA端的int),返回参数jboolean(对应JAVA端的boolean);

    之前findNDI的时候,已经获取了局域网里NDI的视频源信息以及“pNDI_recv”接收句柄,因此重新选择信号源就很方便,只要根据pNDI_recv重新选择对应的sources即可。


CreateRecvNDI具体实现方法

3.StartRecvCapture方法:

    在JAVA端起循环,不断的去调用StartRecvCapture方法,方法返回一个排列好的Bitmap数组(ARGB),每隔40ms,ImageView将返回后的Bitmap数组进行显示。

    JAVA端,输入参数无,返回参数int数组,int数组里存放的是ARGB的Bitmap数据;

    C++端,输入参数JNIEnv,jobject(MainActivity),返回参数jintArray;C++端使用NDIlib_recv_capture_v2方法,传入之前NDI接收的句柄pNDI_recv,,等待返回数据类型是视频(NDIlib_frame_type_video)的数据过来;数据过来后,从p_data指针中获取视频帧,然后进行RGBA到ARGB的转换,最终返回一个jintArray;


StartRecvCapture具体实现方式

4.个人对NDI 6 SDK Android端开发的理解:

    NDI视频流在接收时会涉及到一些解码相关的设置,这些在NDIlib_recv_create_v3_t(创建pNDI_recv接收句柄时声明)这个开放的接口里都有解释,可以从“Processsing.NDI.Recv.h”这个头文件里看到其默认设置,此处的默认色彩控件时NDIlib_recv_color_format_UYVY_BGRA,,即没Alpha通道时是UYVY格式,有Alpha通道时是BGRA格式,在“NDIlib_recv_color_format_e”枚举里也能看到相同的解释(也在Processsing.NDI.Recv.h头文件里查找到)。

    默认情况不带通道的色彩空间UYVY很难转换成能被Bitmap识别到的色彩空间,因此在声明NDIlib_recv_create_v3_t,,我稍加了修改,将里面的色彩空间设置成了“NDIlib_recv_color_format_RGBX_RGBA”,不管有没有Alpha通道,接收到的视频数据里,都包含RGB,能够很方便的被Bitmap识别到。

    此处有心的小伙伴,可以去查看下NDI之前的开发手册,默认的色彩空间配置都是NDIlib_recv_color_format_RGBX_RGBA,,那为什么这次NDI 6里面修改了呢,我的理解是为了加快NDI接收端解码的速率,并且色彩空间的设置里,有一项NDIlib_recv_color_format_fastest的说明,也验证了我的想法。

NDIlib_recv_create_v3_t声明
修改默认的色彩空间为NDIlib_recv_color_format_RGBX_RGBA
NDIlib_recv_color_format_fastest的解释

    通过NDIlib_recv_create_v3_t声明接收句柄,通过NDIlib_find_get_current_sources去发现网络里的NDI源,NDIlib_recv_connect根据这两个输入参数去连接NDI信号源,NDIlib_recv_capture_v2负责将连接好的信号源进行解码并获取到视频帧video_frame,从视频帧里我们也可以获取到跟视频有关的很多信息,如解码后存放的视频帧颜色空间FourCC,视频的长、宽、帧率、数据指针等等。

    色彩空间FourCC依据之前视频解码时的句柄pNDI_recv配置而会对应不同的格式,如之前pNDI_recv设置了NDIlib_recv_color_format_UYVY_BGRA或者NDIlib_recv_color_format_fastest,则video_frame里对应的FourCC格式是NDIlib_FourCC_video_type_UYVY,,如果pNDI_recv设置了NDIlib_recv_color_format_RGBX_RGBA,,则FourCC格式是NDIlib_FourCC_video_type_RGBA或NDIlib_FourCC_video_type_RGBX,我的理解是,NDI在接收解码时,不做过多的干预,只是通过不同颜色空间数据的排列组合进行存放而已,原来发送过来的视频数据是什么编码方式,在NDI接收端就尽量还原成什么方式。

Processing.NDI.structs.h对于NDIlib_video_frame_v2_t声明

5.JNI开发中一些较常用到的方法

    env->NewStringUTF,将c++里的string字符串,转成jstring,传入的是char*指针;

    env->NewObjectArray/NewInArray/NewByteArray等等,创建一个对应格式的JNI数组,并规定好对应的长度;

    env->SetObjectArrayElement/SetIntArrayElement/SetByteArrayElement等等,往数组里传输参数,一般用于for循环遍历;

    env->SetIntArrayRegion//SetByteArrayRegion等等,将整段数组中的数据进行传入;

    env->DeleteLocalRef,释放本地引用;

    C++端调取JAVA端函数的方法:获取JAVA中的对象,myClass=env->GetObjectClass(thiz);env->GetMethodID(),这是返回methodID的方法,输入参数里传入myClass,函数名,以及函数入参(例如“([BIII)V”,[B表示byte数组,I表示整数,V表示void,不用死记,AndroidStudio会自动帮你补全的);env->CallVoidMethod/CallBooleanMethod/CallByteMethod等等,根据之前的获得的methodID,调取JAVA里面的函数方法,传入相应的参数;

4.4、Android手柄控制器的输入和功能定义

    此次开发手头正好有2套手柄,分别是小鸡X2S有线手柄和小鸡G8+射手座无线手柄,用的都是默认的Android输入功能,经过测试者两款手柄的输入方式是一样的,开发的Android程序内都兼容,其他品牌的手柄没做过测试,原理应该类似,都是作为Android设备的游戏手柄输入。

    为什么这次开发中一定要将手柄的输入直接读到Android设备中,而不是通过蓝牙或者有线的方式进入PC端做开发?这个问题也困扰我很久,在实际应用中蓝牙的传输距离有限,Wifi稍微好一点,最好还是Lora天线的传输方式,为了方便开发和扩展,我取了个折中方案-WIFI控制。

小鸡X2S有线手柄
小鸡G8+无线蓝牙手柄

    Android手柄的控制是通过复写MotionEvent和KeyEvent两类事件得到的,其中MotionEvent针对的是摇杆和扳机键这种控制,键值是-1~1的浮点数,KeyEvent对应的是按钮的按下和弹起,键值是0和1的boolean值。

1、MotionEvent的使用:

    在MainActivity里复写onGenericMotionEvent方法,处理手柄的移动事件,此次只针对左右摇杆,我把扳机键放在KeyEvent里用了,经过测试发现,MotionEvent.AXIS_X对应左摇杆的上下,MotionEvent.AXIS_Y对应左摇杆的左右,MotionEvent.AXIS_Z对应右摇杆的上下,MotionEvent.AXIS_RZ对应右摇杆的左右,范围都是-1~1;

    获取到左右摇杆的轴的数值后,将其写入TextView中,方便观察调试。

onGenericMotionEvent方法

2.KeyEvent的使用:

复写onKeyDown和onKeyUp事件,当对应的按键按下去的时候对应的键值为True,当按键弹起来的时候对应的键值为false,示例如下图所示,不同的手柄可能对应不同的键值,换手柄的时候注意先去测试按键对应的值(keyCode);

小鸡手柄对应的部分onKeyDown事件

3、整个手柄按键列表对应如下:(轨道机器人和AGV复合机器人控制,适用于有线和无线连接的小鸡手柄,推荐有线方式,响应更快更稳定)

小鸡手柄按键布局定义

4.5、UDP服务端的数据发送和格式

    UDP数据的发送,紧接视频帧接收(StartRecvCapture)的后面,理论上应该跟视频的帧率相同,例如如果视频是1080 50i,那么发送UDP数据的时间间隔是20ms,但是考虑到界面和JNI数据传递的延时,实际的UDP数据时间间隔应该是不好确定的。

UDP服务端的建立流程如下:

    在MainActivity的onCreate时,调用“startUdp”方法,创建UDP接口,等待客户端连接,当客户端连接成功后,即可拿到客户端的IP地址和客户端本地端口号。

    UDP客户端如果跟服务端建立连接(一般是随便发一组数据),在UDP服务端就可以获取到UDP客户端的IP地址和端口号,此时再将UDP控制数据全部发送过去,本文只针对了单个UDP客户端的连接方法,如果想用UDP服务端做广播发送的话,原理差不多,广播其实就是提前往广播地址先送信号,UDP客户端再从广播地址去获取信号。

UDP协议启动方式

    UDP服务端向UDP客户端发送信号的方法,放在了接收NDI视频流数据(SetUpdateImg)的后面,如下图所示:

UDP服务端向客户端发送数据的方式

五、总结

5.1、遇到的一些坑

1、视频数据色彩空间转换问题

    再NDI 6 SDK(Android)的开发过程中,默认视频接收端色彩空间的编码格式是“NDIlib_recv_color_format_UYVY_BGRA”,即在接收没有Alpha通道视频的情况下,色彩空间是UYVY(采样4:2:2),这种情况下p_data视频指针后对应的数据长度是xres*yres*2,每帧视频数据的排列方式是U0Y0V0Y1U1Y2V1Y3.....,即每4个Y(亮度)信号采样,对应2个U色度信号采样和2个V色度信号采样,每隔像素包含2个字节的长度。

    如果要将UYVY的数据,转成Android能显示的数据,光靠Bitmap无法实现(Bitmap里不包含YUV格式,只有RGB格式),转换起来比较复杂,一般需要先转换成YUV的数据格式,而YUV的数据格式中还包含2种变体,第1种是平面格式(Y、U、V分别存储在不同的平面),第2种是半平面格式(Y一个平面,UV交错一个平面,常见半平面NV12和NV21,NDIlib_FourCC_type_P216表示4:2:2的NV12平面),NV12是U在前V在后,NV21是V在前U在后,转换起来涉及到的过程有点复杂,因此本此开发中最终没采用UYVY的采样方法,而选择了效率相对低一些的“NDIlib_recv_color_format_RGBX_RGBA”色彩空间,该色彩空间的优势是比较容易传递给Bitmap(ARGB888),劣势是接收的效率没有UYUV那么快。

   说到此处,顺便再介绍一下Bitmap中的RGB_565和ARGB888两种色彩空间编码方式,在Android的Bitmap类中,RGB_565表示每个像素的信息存储在2个字节的信息中,R占用5bit,G占用6bit,B占用5bit,不带通道信息,使用short来存储16bit的像素信息,short color = (R & 0x1f) << 11 | (G & 0x3f) << 5 | (B & 0x1f);

    ARGB888,表示每个像素占用4个字节,A占用8bit,R占用8bit,G占用8bit,B占用8bit,int color = (A & 0xff) << 24 | (B & 0xff) << 16 | (G & 0xff) << 8 | (R & 0xff)。

    NDI接收端选择“NDIlib_recv_color_format_RGBX_RGBA”解码,Bitmap里选择ARGB888编码后,在C++里的转换方式如下,传递的数据是jintArray,其中每隔Int占用4个byte,分别用于存储A、R、G、B的数据。

RGBA转ARGB的方式

2、JNI数据传递问题(传递准确性和传递效率)

    开发初期曾尝试过将C++接收到的p_date指针对应的byte数据直接传递给JAVA端,结果显示到JAVA传递数据有误,仔细查看后发现p_data(视频帧数据)对应的格式是“uint_t”(8位无符号int,数据范围0~256),如果通过从C++端传递到JAVA端,一般是通过jbyte的方式进行传递,而jbyte是8为有符号int,数据范围-127~127,这就导致传递过去的数据如果超过127后,显示为负数,后期再将这些数据显示出来会比较麻烦且不直观。

    与此同时,原本开发时的设想是将p_data(视频帧数据)先传递到JAVA端,然后再进行颜色格式的转换(RBGA转ARGB),最后再显示到ImageView上,实际操作中发现,如果将原始视频数据的传递、转换、显示都放到JAVA端的话,最终会导致显示的画面非常的卡顿,很多控件操作(Spinner、Button、Switch)将导致非常的卡顿,因此最终改成了先再C++端转换,然后传递(JAVA端单独的线程),最后显示(每隔40ms,独立的Handler处理)。

5.2、后续的优化建议

    NDI解码效率,我不清楚为啥这次官方给的SDK里面,默认的解码方式是UYVY,查了下在Android端,对YUV数据的小时效率更高,并且也有相应的方法,但是总觉得没有Bitmap那么方便好用,后续优化的话,还是得在UYUV解码上下功夫,看看有没有办法,用最简单的方式,将UYUY的数据显示到Android端,提高整理的解码和显示效率。

    此次官方给的默认SDK里,应该只是针对Full NDI的处理方式,应该还有Advance SDK(本人没测试过,网络问题也不太好拿的到),有针对NDI HX做相应的编解码方案,对于监看而言,NDI HX无疑是更优的解决方案。

       此次开发在Bitmap显示上做了偷懒处理,默认分辨率就是1920x1080,并且处理的视频数据只针对ARGB的色彩空间,后续如果优化的话,需要增加更多的兼容性,例如4K HDR的支持,例如各种色彩空间的转换和显示等。

5.3、最终显示效果

最终显示效果图
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,591评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,448评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,823评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,204评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,228评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,190评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,078评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,923评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,334评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,550评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,727评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,428评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,022评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,672评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,826评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,734评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,619评论 2 354

推荐阅读更多精彩内容